From fd714ea2eb8c9559686ed3558b9091d6f9c2e0a2 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 7 Jul 2022 17:07:47 +0200 Subject: [PATCH 01/23] Fix timeouts caused by a slow machine - use different watch message to wait for csv - fix handling of received CancelSwap message - send OpeningTxBroadcasted message before opening confirmed - fixed claim by coop and csv transactions by including premium - do not handle user commands during swap-in create - do not send CancelSwap message for failures during swap-in create - reverse txid when making transaction outpoints - clarify comments to distinguish when tx is published vs confirmed --- .../eclair/integration/basic/fixtures/MinimalNodeFixture.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 20bab2e360..da1c24e8b7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.payment.send.PaymentInitiator import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol.IPAddress import fr.acinq.eclair.{BlockHeight, MilliSatoshi, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases, TestFeeEstimator} +import org.scalatest.concurrent.PatienceConfiguration import org.scalatest.concurrent.{Eventually, IntegrationPatience} import org.scalatest.{Assertions, EitherValues} @@ -180,7 +181,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat watch1.replyTo ! WatchFundingConfirmedTriggered(blockHeight, txIndex, fundingTx) watch2.replyTo ! WatchFundingConfirmedTriggered(blockHeight, txIndex, fundingTx) - eventually { + eventually(PatienceConfiguration.Timeout(2 seconds), PatienceConfiguration.Interval(1 second)) { assert(getChannelState(node1, channelId) == NORMAL) assert(getChannelState(node2, channelId) == NORMAL) } From 7678204418b15bcbb9620fb4e63b3dc34cb5f5c3 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 31 Mar 2022 17:23:40 +0200 Subject: [PATCH 02/23] Add serialization for PeerSwap messages --- .../eclair/json/PeerSwapJsonSerializers.scala | 103 +++++++++++++++ .../protocol/LightningMessageCodecs.scala | 9 ++ .../wire/protocol/LightningMessageTypes.scala | 27 ++++ .../wire/protocol/PeerSwapMessageCodecs.scala | 95 ++++++++++++++ .../swap/PeerSwapJsonSerializersSpec.scala | 92 ++++++++++++++ .../swap/PeerSwapMessageCodecsSpec.scala | 120 ++++++++++++++++++ .../fr/acinq/eclair/swap/PeerSwapSpec.scala | 47 +++++++ 7 files changed, 493 insertions(+) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/json/PeerSwapJsonSerializers.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PeerSwapMessageCodecs.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapJsonSerializersSpec.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/PeerSwapJsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/PeerSwapJsonSerializers.scala new file mode 100644 index 0000000000..9c446963c6 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/PeerSwapJsonSerializers.scala @@ -0,0 +1,103 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.json + +import fr.acinq.eclair.wire.protocol._ +import org.json4s.JsonAST._ +import org.json4s.jackson.Serialization +import org.json4s.{Formats, JField, JObject, JString, jackson} + +object SwapInRequestMessageSerializer extends MinimalSerializer({ + case x: SwapInRequest => JObject(List( + JField("protocol_version", JInt(x.protocolVersion)), + JField("swap_id", JString(x.swapId)), + JField("asset", JString(x.asset)), + JField("network", JString(x.network)), + JField("scid", JString(x.scid)), + JField("amount", JInt(x.amount)), + JField("pubkey", JString(x.pubkey)) + )) +}) + +object SwapOutRequestMessageSerializer extends MinimalSerializer({ + case x: SwapOutRequest => JObject(List( + JField("protocol_version", JInt(x.protocolVersion)), + JField("swap_id", JString(x.swapId)), + JField("asset", JString(x.asset)), + JField("network", JString(x.network)), + JField("scid", JString(x.scid)), + JField("amount", JInt(x.amount)), + JField("pubkey", JString(x.pubkey)) + )) +}) + +object SwapInAgreementMessageSerializer extends MinimalSerializer({ + case x: SwapInAgreement => JObject(List( + JField("protocol_version", JInt(x.protocolVersion)), + JField("swap_id", JString(x.swapId)), + JField("pubkey", JString(x.pubkey)), + JField("premium", JInt(x.premium)) + )) +}) + +object SwapOutAgreementMessageSerializer extends MinimalSerializer({ + case x: SwapOutAgreement => JObject(List( + JField("protocol_version", JLong(x.protocolVersion)), + JField("swap_id", JString(x.swapId)), + JField("pubkey", JString(x.pubkey)), + JField("payreq", JString(x.payreq)) + )) +}) + +object OpeningTxBroadcastedMessageSerializer extends MinimalSerializer({ + case x: OpeningTxBroadcasted => JObject(List( + JField("swap_id", JString(x.swapId)), + JField("payreq", JString(x.payreq)), + JField("tx_id", JString(x.txId)), + JField("script_out", JInt(x.scriptOut)), + JField("blinding_key", JString(x.blindingKey)) + )) +}) + +object CancelMessageSerializer extends MinimalSerializer({ + case x: CancelSwap => JObject(List( + JField("swap_id", JString(x.swapId)), + JField("message", JString(x.message)) + )) +}) + +object CoopCloseMessageSerializer extends MinimalSerializer({ + case x: CoopClose => JObject(List( + JField("swap_id", JString(x.swapId)), + JField("message", JString(x.message)), + JField("privkey", JString(x.privkey)) + )) +}) + +object PeerSwapJsonSerializers { + + implicit val serialization: Serialization.type = jackson.Serialization + + implicit val formats: Formats = org.json4s.DefaultFormats + + SwapInRequestMessageSerializer + + SwapInAgreementMessageSerializer + + SwapOutAgreementMessageSerializer + + SwapOutRequestMessageSerializer + + OpeningTxBroadcastedMessageSerializer + + CancelMessageSerializer + + CoopCloseMessageSerializer +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index 13995723ad..d3141934be 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -19,6 +19,7 @@ package fr.acinq.eclair.wire.protocol import fr.acinq.bitcoin.scalacompat.ScriptWitness import fr.acinq.eclair.wire.Monitoring.{Metrics, Tags} import fr.acinq.eclair.wire.protocol.CommonCodecs._ +import fr.acinq.eclair.wire.protocol.PeerSwapMessageCodecs._ import fr.acinq.eclair.{Feature, Features, InitFeature, KamonExt} import scodec.bits.{BitVector, ByteVector, HexStringSyntax} import scodec.codecs._ @@ -463,6 +464,14 @@ object LightningMessageCodecs { .typecase(264, replyChannelRangeCodec) .typecase(265, gossipTimestampFilterCodec) .typecase(513, onionMessageCodec) + // TODO: move PeerSwap message handling to a plugin + .typecase(42069, swapInRequestCodec) + .typecase(42071, swapOutRequestCodec) + .typecase(42073, swapInAgreementCodec) + .typecase(42075, swapOutAgreementCodec) + .typecase(42077, openingTxBroadcastedCodec) + .typecase(42079, canceledCodec) + .typecase(42081, coopCloseCodec) // NB: blank lines to minimize merge conflicts // diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 8cc0f4d2c8..3689a16609 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -22,9 +22,11 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, ScriptWitness, Transaction} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.{ChannelFlags, ChannelType} +import fr.acinq.eclair.json.PeerSwapJsonSerializers import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.wire.protocol.ChannelReadyTlv.ShortChannelIdTlv import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64, isAsciiPrintable} +import org.json4s.jackson.Serialization import scodec.bits.ByteVector import java.net.{Inet4Address, Inet6Address, InetAddress} @@ -49,6 +51,7 @@ sealed trait HasTemporaryChannelId extends LightningMessage { def temporaryChann sealed trait HasChannelId extends LightningMessage { def channelId: ByteVector32 } // <- not in the spec sealed trait HasChainHash extends LightningMessage { def chainHash: ByteVector32 } // <- not in the spec sealed trait HasSerialId extends LightningMessage { def serialId: UInt64 } // <- not in the spec +sealed trait HasSwapId extends LightningMessage { def swapId: String } // <- not in the spec sealed trait UpdateMessage extends HtlcMessage // <- not in the spec sealed trait HtlcSettlementMessage extends UpdateMessage { def id: Long } // <- not in the spec // @formatter:on @@ -489,6 +492,30 @@ case class GossipTimestampFilter(chainHash: ByteVector32, firstTimestamp: Timest case class OnionMessage(blindingKey: PublicKey, onionRoutingPacket: OnionRoutingPacket, tlvStream: TlvStream[OnionMessageTlv] = TlvStream.empty) extends LightningMessage +sealed trait PeerSwapMessage extends LightningMessage + +sealed abstract class JSonBlobMessage() extends PeerSwapMessage { + def json: String = { + Serialization.write(this)(PeerSwapJsonSerializers.formats) + } +} + +case class SwapInRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends JSonBlobMessage with HasSwapId + +case class SwapOutRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends JSonBlobMessage with HasSwapId + +case class SwapInAgreement(protocolVersion: Long, swapId: String, pubkey: String, premium: Long) extends JSonBlobMessage with HasSwapId + +case class SwapOutAgreement(protocolVersion: Long, swapId: String, pubkey: String, payreq: String) extends JSonBlobMessage with HasSwapId + +case class OpeningTxBroadcasted(swapId: String, payreq: String, txId: String, scriptOut: Long, blindingKey: String) extends JSonBlobMessage with HasSwapId + +case class CancelSwap(swapId: String, message: String) extends JSonBlobMessage with HasSwapId + +case class CoopClose(swapId: String, message: String, privkey: String) extends JSonBlobMessage with HasSwapId + +case class UnknownPeerSwapMessage(tag: Int, data: ByteVector) extends PeerSwapMessage + // NB: blank lines to minimize merge conflicts // diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PeerSwapMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PeerSwapMessageCodecs.scala new file mode 100644 index 0000000000..f99c0770f9 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PeerSwapMessageCodecs.scala @@ -0,0 +1,95 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.wire.protocol + +import fr.acinq.eclair.KamonExt +import fr.acinq.eclair.json.PeerSwapJsonSerializers.formats +import fr.acinq.eclair.wire.Monitoring.{Metrics, Tags} +import fr.acinq.eclair.wire.protocol.CommonCodecs._ +import org.json4s._ +import org.json4s.jackson.JsonMethods._ +import org.json4s.jackson.Serialization +import scodec.bits.BitVector +import scodec.codecs._ +import scodec.{Attempt, Codec} + +/** + * Created by remyers on 29/03/2022. + */ +object PeerSwapMessageCodecs { + + val swapInRequestCodec: Codec[SwapInRequest] = limitedSizeBytes(65533, utf8) + .xmap(a => Serialization.read[SwapInRequest](compact(render(parse(a).camelizeKeys))), + b => compact(render(parse(Serialization.write(b)).snakizeKeys))) + + val swapOutRequestCodec: Codec[SwapOutRequest] = limitedSizeBytes(65533, utf8) + .xmap(a => Serialization.read[SwapOutRequest](compact(render(parse(a).camelizeKeys))), + b => compact(render(parse(Serialization.write(b)).snakizeKeys))) + + val swapInAgreementCodec: Codec[SwapInAgreement] = limitedSizeBytes(65533, utf8) + .xmap(a => Serialization.read[SwapInAgreement](compact(render(parse(a).camelizeKeys))), + b => compact(render(parse(Serialization.write(b)).snakizeKeys))) + + val swapOutAgreementCodec: Codec[SwapOutAgreement] = limitedSizeBytes(65533, utf8) + .xmap(a => Serialization.read[SwapOutAgreement](compact(render(parse(a).camelizeKeys))), + b => compact(render(parse(Serialization.write(b)).snakizeKeys))) + + val openingTxBroadcastedCodec: Codec[OpeningTxBroadcasted] = limitedSizeBytes(65533, utf8) + .xmap(a => Serialization.read[OpeningTxBroadcasted](compact(render(parse(a).camelizeKeys))), + b => compact(render(parse(Serialization.write(b)).snakizeKeys))) + + val canceledCodec: Codec[CancelSwap] = limitedSizeBytes(65533, utf8) + .xmap(a => Serialization.read[CancelSwap](compact(render(parse(a).camelizeKeys))), + b => compact(render(parse(Serialization.write(b)).snakizeKeys))) + + val coopCloseCodec: Codec[CoopClose] = limitedSizeBytes(65533, utf8) + .xmap(a => Serialization.read[CoopClose](compact(render(parse(a).camelizeKeys))), + b => compact(render(parse(Serialization.write(b)).snakizeKeys))) + + val unknownPeerSwapMessageCodec: Codec[UnknownPeerSwapMessage] = ( + ("tag" | uint16) :: + ("message" | varsizebinarydata) + ).as[UnknownPeerSwapMessage] + + val peerSwapMessageCodec: DiscriminatorCodec[PeerSwapMessage, Int] = discriminated[PeerSwapMessage].by(uint16) + .typecase(42069, swapInRequestCodec) + .typecase(42071, swapOutRequestCodec) + .typecase(42073, swapInAgreementCodec) + .typecase(42075, swapOutAgreementCodec) + .typecase(42077, openingTxBroadcastedCodec) + .typecase(42079, canceledCodec) + .typecase(42081, coopCloseCodec) + + val peerSwapMessageCodecWithFallback: Codec[PeerSwapMessage] = discriminatorWithDefault(peerSwapMessageCodec, unknownPeerSwapMessageCodec.upcast) + + val meteredPeerSwapMessageCodec: Codec[PeerSwapMessage] = Codec[PeerSwapMessage]( + (msg: PeerSwapMessage) => KamonExt.time(Metrics.EncodeDuration.withTag(Tags.MessageType, msg.getClass.getSimpleName))(peerSwapMessageCodecWithFallback.encode(msg)), + (bits: BitVector) => { + // this is a bit more involved, because we don't know beforehand what the type of the message will be + val begin = System.nanoTime() + val res = peerSwapMessageCodecWithFallback.decode(bits) + val end = System.nanoTime() + val messageType = res match { + case Attempt.Successful(decoded) => decoded.value.getClass.getSimpleName + case Attempt.Failure(_) => "unknown" + } + Metrics.DecodeDuration.withTag(Tags.MessageType, messageType).record(end - begin) + res + } + ) + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapJsonSerializersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapJsonSerializersSpec.scala new file mode 100644 index 0000000000..0737687b03 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapJsonSerializersSpec.scala @@ -0,0 +1,92 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.eclair.json.PeerSwapJsonSerializers.formats +import fr.acinq.eclair.wire.protocol._ +import org.json4s.jackson.JsonMethods.{compact, parse, render} +import org.json4s.jackson.Serialization + +/** + * Created by remyers on 03/30/2022. + */ + +class PeerSwapJsonSerializersSpec extends PeerSwapSpec { + test("encode/decode SwapInRequest to/from json") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","asset":"$asset","network":"$network","scid":"$shortId","amount":$amount,"pubkey":"$pubkey"}""".stripMargin + val obj = SwapInRequest(protocolVersion = protocolVersion, swapId = swapId.toHex, asset = asset, network = network, scid = shortId.toString, amount = amount, pubkey = pubkey.toString) + val encoded = compact(render(parse(Serialization.write(obj)).snakizeKeys)) + val decoded = Serialization.read[SwapInRequest](compact(render(parse(json).camelizeKeys))) + assert(decoded === obj) + assert(encoded === json) + } + + test("encode/decode SwapOutRequest to/from json") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","asset":"$asset","network":"$network","scid":"$shortId","amount":$amount,"pubkey":"$pubkey"}""".stripMargin + val obj = SwapOutRequest(protocolVersion = protocolVersion, swapId = swapId.toHex, asset = asset, network = network, scid = shortId.toString, amount = amount, pubkey = pubkey.toString) + val encoded = compact(render(parse(Serialization.write(obj)).snakizeKeys)) + val decoded = Serialization.read[SwapOutRequest](compact(render(parse(json).camelizeKeys))) + assert(encoded === json) + assert(decoded === obj) + } + + test("encode/decode SwapInAgreement to/from json") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","pubkey":"$pubkey","premium":$premium}""".stripMargin + val obj = SwapInAgreement(protocolVersion = protocolVersion, swapId = swapId.toHex, pubkey = pubkey.toString, premium = premium) + val encoded = compact(render(parse(Serialization.write(obj)).snakizeKeys)) + val decoded = Serialization.read[SwapInAgreement](compact(render(parse(json).camelizeKeys))) + assert(encoded === json) + assert(decoded === obj) + } + + test("encode/decode SwapOutAgreement json") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","pubkey":"$pubkey","payreq":"$payreq"}""".stripMargin + val obj = SwapOutAgreement(protocolVersion = protocolVersion, swapId = swapId.toHex, pubkey = pubkey.toString, payreq = payreq) + val encoded = compact(render(parse(Serialization.write(obj)).snakizeKeys)) + val decoded = Serialization.read[SwapOutAgreement](compact(render(parse(json).camelizeKeys))) + assert(encoded === json) + assert(decoded === obj) + } + + test("encode/decode OpeningTxBroadcasted to/from json") { + val json = s"""{"swap_id":"${swapId.toHex}","payreq":"$payreq","tx_id":"$txid","script_out":$scriptOut,"blinding_key":"$blindingKey"}""".stripMargin + val obj = OpeningTxBroadcasted(swapId = swapId.toHex, txId = txid, payreq = payreq, scriptOut = scriptOut, blindingKey = blindingKey) + val encoded = compact(render(parse(Serialization.write(obj)).snakizeKeys)) + val decoded = Serialization.read[OpeningTxBroadcasted](compact(render(parse(json).camelizeKeys))) + assert(encoded === json) + assert(decoded === obj) + } + + test("encode/decode Cancel to/from json") { + val json = s"""{"swap_id":"${swapId.toHex}","message":"$message"}""".stripMargin + val obj = CancelSwap(swapId = swapId.toHex, message = message) + val encoded = compact(render(parse(Serialization.write(obj)).snakizeKeys)) + val decoded = Serialization.read[CancelSwap](compact(render(parse(json).camelizeKeys))) + assert(encoded === json) + assert(decoded === obj) + } + + test("encode/decode CoopClose to/from json") { + val json = s"""{"swap_id":"${swapId.toHex}","message":"$message","privkey":"$privkey"}""".stripMargin + val obj = CoopClose(swapId = swapId.toHex, message = message, privkey = privkey.toString) + val encoded = compact(render(parse(Serialization.write(obj)).snakizeKeys)) + val decoded = Serialization.read[CoopClose](compact(render(parse(json).camelizeKeys))) + assert(encoded === json) + assert(decoded === obj) + } + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala new file mode 100644 index 0000000000..c351e1cef4 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala @@ -0,0 +1,120 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.eclair.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback +import fr.acinq.eclair.wire.protocol._ +import scodec.bits.HexStringSyntax + +/** + * Created by remyers on 30/03/2022. + */ + +class PeerSwapMessageCodecsSpec extends PeerSwapSpec { + + test("encode/decode SwapInRequest messages to/from binary") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","asset":"$asset","network":"$network","scid":"$shortId","amount":$amount,"pubkey":"$pubkey"}""".stripMargin + val bin = hex"a4557b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + val obj = SwapInRequest(protocolVersion, swapId.toHex, asset, network, shortId.toString, amount, pubkey.toString()) + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode SwapOutRequest messages to/from binary") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","asset":"$asset","network":"$network","scid":"$shortId","amount":$amount,"pubkey":"$pubkey"}""".stripMargin + val obj = SwapOutRequest(protocolVersion, swapId.toHex, asset, network, shortId.toString, amount, pubkey.toString()) + val bin = hex"a4577b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode SwapInAgreement messages to/from binary") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","pubkey":"$pubkey","premium":$premium}""".stripMargin + val obj = SwapInAgreement(protocolVersion = protocolVersion, swapId = swapId.toHex, pubkey = pubkey.toString, premium = premium) + val bin = hex"a4597b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c227072656d69756d223a313030307d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode SwapOutAgreement messages to/from binary") { + val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","pubkey":"$pubkey","payreq":"$payreq"}""".stripMargin + val obj = SwapOutAgreement(protocolVersion = protocolVersion, swapId = swapId.toHex, pubkey = pubkey.toString, payreq = payreq) + val bin = hex"a45b7b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c22706179726571223a22696e766f6963652068657265227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode OpeningTxBroadcasted messages to/from binary") { + val json = s"""{"swap_id":"${swapId.toHex}","payreq":"$payreq","tx_id":"$txid","script_out":$scriptOut,"blinding_key":"$blindingKey"}""".stripMargin + val obj = OpeningTxBroadcasted(swapId = swapId.toHex, payreq = payreq, txId = txid, scriptOut = scriptOut, blindingKey = blindingKey) + val bin = hex"a45d7b22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c22706179726571223a22696e766f6963652068657265222c2274785f6964223a2233386238353463353639666634623862323565366565656333316432316365346131656536646263326166633765666462343463383164353133623462666663222c227363726970745f6f7574223a302c22626c696e64696e675f6b6579223a22227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode Cancel messages to/from binary") { + val json = s"""{"swap_id":"${swapId.toHex}","message":"$message"}""".stripMargin + val obj = CancelSwap(swapId = swapId.toHex, message = message) + val bin = hex"a45f7b22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226d657373616765223a2261206d657373616765227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode CoopClose messages to/from binary") { + val json = s"""{"swap_id":"${swapId.toHex}","message":"$message","privkey":"$privkey"}""".stripMargin + val obj = CoopClose(swapId = swapId.toHex, message = message, privkey = privkey.toString) + val bin = hex"a4617b22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226d657373616765223a2261206d657373616765222c22707269766b6579223a223c707269766174655f6b65793e227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapSpec.scala new file mode 100644 index 0000000000..7460f7c58b --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapSpec.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.bitcoin.scalacompat.Crypto +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.eclair.ShortChannelId +import org.scalatest.TryValues.convertTryToSuccessOrFailure +import org.scalatest.funsuite.AnyFunSuite +import scodec.bits._ + +/** + * Created by remyers on 04/04/2022. + */ + +class PeerSwapSpec extends AnyFunSuite { + val protocolVersion = 2 + val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" + val asset = "" + val network = "regtest" + val shortId: ShortChannelId = ShortChannelId.fromCoordinates("539268x845x1").success.get + val amount = 10000 + val pubkey: PublicKey = dummyKey(1).publicKey + val premium = 1000 + val payreq = "invoice here" + val txid = "38b854c569ff4b8b25e6eeec31d21ce4a1ee6dbc2afc7efdb44c81d513b4bffc" + val scriptOut = 0 + val blindingKey = "" + val message = "a message" + val privkey: PrivateKey = dummyKey(1) + + def dummyKey(fill: Byte): Crypto.PrivateKey = PrivateKey(ByteVector.fill(32)(fill)) +} From ef88c3ee6daed0f9cc18902321405557225630f8 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 21 Jun 2022 14:55:30 +0200 Subject: [PATCH 03/23] Add on-chain transactions for peerswap --- .../fr/acinq/eclair/swap/SwapScripts.scala | 69 ++++++++ .../acinq/eclair/swap/SwapTransactions.scala | 159 ++++++++++++++++++ .../eclair/transactions/Transactions.scala | 4 + .../eclair/swap/SwapTransactionsSpec.scala | 105 ++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapScripts.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapScripts.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapScripts.scala new file mode 100644 index 0000000000..8d527d7c5a --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapScripts.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat._ +import fr.acinq.eclair.CltvExpiryDelta +import fr.acinq.eclair.transactions.Scripts +import fr.acinq.eclair.transactions.Scripts.der +import scodec.bits.ByteVector + +/** + * Created by remyers on 06/05/2022 + */ +object SwapScripts { + val claimByCsvDelta: CltvExpiryDelta = CltvExpiryDelta(1008) + + /** + * The opening transaction output script is a P2WSH: + */ + def swapOpening(makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector, csvDelay: CltvExpiryDelta = claimByCsvDelta): Seq[ScriptElt] = { + // @formatter:off + // To you with revocation key + OP_PUSHDATA(makerPubkey) :: OP_CHECKSIG :: OP_NOTIF :: + OP_PUSHDATA(makerPubkey) :: OP_CHECKSIG :: OP_NOTIF :: + OP_SIZE :: Scripts.encodeNumber(32) :: OP_EQUALVERIFY :: OP_SHA256 :: OP_PUSHDATA(paymentHash) :: OP_EQUALVERIFY :: + OP_ENDIF :: + OP_PUSHDATA(takerPubkey) :: OP_CHECKSIG :: + OP_ELSE :: + Scripts.encodeNumber(csvDelay.toInt) :: OP_CHECKSEQUENCEVERIFY :: + OP_ENDIF :: Nil + // @formatter:on + } + + /** + * This is the desired way to finish a swap. The taker sends the funds to its address by revealing the preimage of the swap invoice. + * witness: <> <> + */ + def witnessClaimByInvoice(takerSig: ByteVector64, paymentPreimage: ByteVector32, redeemScript: ByteVector): ScriptWitness = + ScriptWitness(der(takerSig) :: paymentPreimage.bytes :: ByteVector.empty :: ByteVector.empty :: redeemScript :: Nil) + + /** + * This is the way to cooperatively finish a swap. The maker refunds to its address without waiting for the CSV. + * witness: <> + */ + def witnessClaimByCoop(takerSig: ByteVector64, makerSig: ByteVector64, redeemScript: ByteVector): ScriptWitness = + ScriptWitness(der(takerSig) :: der(makerSig) :: ByteVector.empty :: redeemScript :: Nil) + + /** + * This is the way to finish a swap if the invoice was not paid and the taker did not send a coop_close message. After the relative locktime has passed, the maker refunds to them. + * witness: + */ + def witnessClaimByCsv(makerSig: ByteVector64, redeemScript: ByteVector): ScriptWitness = + ScriptWitness(der(makerSig) :: redeemScript :: Nil) +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala new file mode 100644 index 0000000000..eeb88b7ddc --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala @@ -0,0 +1,159 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.bitcoin.SigHash.SIGHASH_ALL +import fr.acinq.bitcoin.SigVersion.SIGVERSION_WITNESS_V0 +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.Script._ +import fr.acinq.bitcoin.scalacompat.{TxOut, _} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.swap.SwapScripts._ +import fr.acinq.eclair.transactions.Scripts.der +import fr.acinq.eclair.transactions.Transactions.{InputInfo, weight2fee} +import scodec.bits.ByteVector + +/** + * Created by remyers on 06/05/2022. + */ +object SwapTransactions { + + /** + * This default sig takes 72B when encoded in DER (incl. 1B for the trailing sig hash), it is used for fee estimation + * It is 72 bytes because our signatures are normalized (low-s) and will take up 72 bytes at most in DER format + */ + val PlaceHolderSig: ByteVector64 = ByteVector64(ByteVector.fill(64)(0xaa)) + assert(der(PlaceHolderSig).size == 72) + + val claimByInvoiceTxWeight = 593 // TODO: add test to confirm this is the actual weight of claimByInvoice tx + + def makeSwapOpeningInputInfo(fundingTxId: ByteVector32, fundingTxOutputIndex: Int, amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): InputInfo = { + val redeemScript = swapOpening(makerPubkey, takerPubkey, paymentHash) + val openingTxOut = makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash) + InputInfo(OutPoint(fundingTxId, fundingTxOutputIndex), openingTxOut, write(redeemScript)) + } + + def makeSwapOpeningTxOut(amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): TxOut = { + val redeemScript = swapOpening(makerPubkey, takerPubkey, paymentHash) + TxOut(amount, pay2wsh(redeemScript)) + } + + def validOpeningTx(openingTx: Transaction, scriptOut: Long, amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): Boolean = + openingTx match { + case Transaction(2, _, txOut, 0) if txOut(scriptOut.toInt) == makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash) => true + case _ => false + } + + /** + * This is the desired way to finish a swap. The taker sends the funds to its address by revealing the preimage of the swap invoice. + * + * txin count: 1 + * txin[0] outpoint: tx_id and script_output from the opening_tx_broadcasted message + * txin[0] sequence: 0 + * txin[0] script bytes: 0 + * txin[0] witness: <> <> + * + */ + def makeSwapClaimByInvoiceTx(amount: Satoshi, makerPubkey: PublicKey, takerPrivkey: PrivateKey, paymentPreimage: ByteVector32, feeratePerKw: FeeratePerKw, openingTxId: ByteVector32, openingOutIndex: Int): Transaction = { + val redeemScript = swapOpening(makerPubkey, takerPrivkey.publicKey, Crypto.sha256(paymentPreimage)) + + val tx = Transaction( + version = 2, + txIn = TxIn(OutPoint(openingTxId, openingOutIndex), ByteVector.empty, 0) :: Nil, + txOut = TxOut(0 sat, pay2wpkh(takerPrivkey.publicKey)) :: Nil, + lockTime = 0) + + // spend input less tx fee + val weight = tx.updateWitness(0, witnessClaimByInvoice(PlaceHolderSig, paymentPreimage, write(redeemScript))).weight() + val fee = weight2fee(feeratePerKw, weight) + val amountLessFee = amount - fee + val txLessFees = tx.copy(txOut = tx.txOut.head.copy(amount = amountLessFee) :: Nil) + + val sigDER = Transaction.signInput(txLessFees, inputIndex = 0, previousOutputScript = redeemScript, SIGHASH_ALL, amount, SIGVERSION_WITNESS_V0, takerPrivkey) + val takerSig = Crypto.der2compact(sigDER) + + txLessFees.updateWitness(0, witnessClaimByInvoice(takerSig, paymentPreimage, write(redeemScript))) + } + + /** + * This is the way to cooperatively finish a swap. The maker refunds to its address without waiting for the CSV. + * + * txin count: 1 + * txin[0] outpoint: tx_id and script_output from the opening_tx_broadcasted message + * txin[0] sequence: 0 + * txin[0] script bytes: 0 + * txin[0] witness: <> + * + */ + def makeSwapClaimByCoopTx(amount: Satoshi, makerPrivkey: PrivateKey, takerPrivkey: PrivateKey, paymentHash: ByteVector, feeratePerKw: FeeratePerKw, openingTxId: ByteVector32, openingOutIndex: Int): Transaction = { + val redeemScript = swapOpening(makerPrivkey.publicKey, takerPrivkey.publicKey, paymentHash) + + val tx = Transaction( + version = 2, + txIn = TxIn(OutPoint(openingTxId, openingOutIndex), ByteVector.empty, 0) :: Nil, + txOut = TxOut(0 sat, pay2wpkh(makerPrivkey.publicKey)) :: Nil, + lockTime = 0) + + // spend input less tx fee + val weight = tx.updateWitness(0, witnessClaimByCoop(PlaceHolderSig, PlaceHolderSig, write(redeemScript))).weight() + val fee = weight2fee(feeratePerKw, weight) + val amountLessFee = amount - fee + val txLessFees = tx.copy(txOut = tx.txOut.head.copy(amount = amountLessFee) :: Nil) + + val takerSigDER = Transaction.signInput(txLessFees, inputIndex = 0, previousOutputScript = redeemScript, SIGHASH_ALL, amount, SIGVERSION_WITNESS_V0, takerPrivkey) + val takerSig = Crypto.der2compact(takerSigDER) + val makerSigDER = Transaction.signInput(txLessFees, inputIndex = 0, previousOutputScript = redeemScript, SIGHASH_ALL, amount, SIGVERSION_WITNESS_V0, makerPrivkey) + val makerSig = Crypto.der2compact(makerSigDER) + + txLessFees.updateWitness(0, witnessClaimByCoop(takerSig, makerSig, write(redeemScript))) + } + + /** + * This is the way to finish a swap if the invoice was not paid and the taker did not send a coop_close message. After the relative locktime has passed, the maker refunds to them. + * + * txin count: 1 + * txin[0] outpoint: tx_id and script_output from the opening_tx_broadcasted message + * txin[0] sequence: + * for btc as asset: 0x3F0 corresponding to the CSV of 1008 + * for lbtc as asset: 0x3C corresponding to the CSV of 60 + * txin[0] script bytes: 0 + * txin[0] witness: + * + */ + def makeSwapClaimByCsvTx(amount: Satoshi, makerPrivkey: PrivateKey, takerPubkey: PublicKey, paymentHash: ByteVector, feeratePerKw: FeeratePerKw, openingTxId: ByteVector32, openingOutIndex: Int): Transaction = { + + val redeemScript = swapOpening(makerPrivkey.publicKey, takerPubkey, paymentHash) + + val tx = Transaction( + version = 2, + txIn = TxIn(OutPoint(openingTxId, openingOutIndex), ByteVector.empty, claimByCsvDelta.toInt) :: Nil, + txOut = TxOut(0 sat, pay2wpkh(makerPrivkey.publicKey)) :: Nil, + lockTime = 0) + + // spend input less tx fee + val weight = tx.updateWitness(0, witnessClaimByCsv(PlaceHolderSig, write(redeemScript))).weight() + val fee = weight2fee(feeratePerKw, weight) + val amountLessFee = amount - fee + val txLessFees = tx.copy(txOut = tx.txOut.head.copy(amount = amountLessFee) :: Nil) + + val makerSigDER = Transaction.signInput(txLessFees, inputIndex = 0, previousOutputScript = redeemScript, SIGHASH_ALL, amount, SIGVERSION_WITNESS_V0, makerPrivkey) + val makerSig = Crypto.der2compact(makerSigDER) + + txLessFees.updateWitness(0, witnessClaimByCsv(makerSig, write(redeemScript))) + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 1deb5395ff..70502e1fe1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -163,6 +163,10 @@ object Transactions { sealed trait TxGenerationSkipped case object OutputNotFound extends TxGenerationSkipped { override def toString = "output not found (probably trimmed)" } case object AmountBelowDustLimit extends TxGenerationSkipped { override def toString = "amount is below dust limit" } + + case class SwapClaimByInvoiceTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbyinvoice-tx" } + case class SwapClaimByCoopTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycoop-tx" } + case class SwapClaimByCsvTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycsv-tx" } // @formatter:on /** diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala new file mode 100644 index 0000000000..beb6463832 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala @@ -0,0 +1,105 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor.typed.ActorRef +import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps} +import akka.pattern.pipe +import akka.testkit.TestProbe +import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong} +import fr.acinq.eclair._ +import fr.acinq.eclair.blockchain.DummyOnChainWallet +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse +import fr.acinq.eclair.blockchain.bitcoind.BitcoindService +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.publish.FinalTxPublisher +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext +import fr.acinq.eclair.swap.SwapTransactions._ +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx, SwapClaimByInvoiceTx, checkSpendable} +import grizzled.slf4j.Logging +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuiteLike + +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global + +/** + * Created by remyers on 06/05/2022. + */ + +class SwapTransactionsSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with BeforeAndAfterAll with Logging { + val makerRefundPriv: PrivateKey = PrivateKey(randomBytes32()) + val takerPaymentPriv: PrivateKey = PrivateKey(randomBytes32()) + val paymentPreimage: ByteVector32 = randomBytes32() + val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage) + val amount: Satoshi = 30000 sat + val openingTxId: ByteVector32 = randomBytes32() + val openingTxOut: Int = 0 + val claimInput: Transactions.InputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxOut, amount, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + + val csvDelay = 20 + val localDustLimit: Satoshi = Satoshi(546) + val feeratePerKw: FeeratePerKw = FeeratePerKw(10000 sat) + val wallet = new DummyOnChainWallet() + + + override def beforeAll(): Unit = { + startBitcoind() + waitForBitcoindReady() + } + + override def afterAll(): Unit = { + stopBitcoind() + } + + def createFixture(): Fixture = { + val probe = TestProbe() + val watcher = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + val publisher = system.spawnAnonymous(FinalTxPublisher(TestConstants.Alice.nodeParams, bitcoinClient, watcher.ref.toTyped, TxPublishContext(UUID.randomUUID(), randomKey().publicKey, None))) + Fixture(bitcoinClient, publisher, watcher, probe) + } + + case class Fixture(bitcoinClient: BitcoinCoreClient, publisher: ActorRef[FinalTxPublisher.Command], watcher: TestProbe, probe: TestProbe) + + test("check validity of PeerSwap claim transactions") { + val f = createFixture() + import f._ + + val swapTxOut = makeSwapOpeningTxOut(amount, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + wallet.makeFundingTx(swapTxOut.publicKeyScript, amount, feeratePerKw).pipeTo(probe.ref) + val response = probe.expectMsgType[MakeFundingTxResponse] + val openingTx = response.fundingTx + val openingTxOut = response.fundingTxOutputIndex + val inputInfo = makeSwapOpeningInputInfo(openingTx.hash, openingTxOut, amount, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + + val swapClaimByInvoiceTx = makeSwapClaimByInvoiceTx(amount, makerRefundPriv.publicKey, takerPaymentPriv, paymentPreimage, feeratePerKw, openingTx.hash, openingTxOut) + assert(swapClaimByInvoiceTx.txIn.head.sequence == 0) + assert(checkSpendable(SwapClaimByInvoiceTx(inputInfo, swapClaimByInvoiceTx)).isSuccess) + + val swapClaimByCoopTx = makeSwapClaimByCoopTx(amount, makerRefundPriv, takerPaymentPriv, paymentHash, feeratePerKw, openingTx.hash, openingTxOut) + assert(swapClaimByCoopTx.txIn.head.sequence == 0) + assert(checkSpendable(SwapClaimByCoopTx(inputInfo, swapClaimByCoopTx)).isSuccess) + + val swapClaimByCsvTx = makeSwapClaimByCsvTx(amount, makerRefundPriv, takerPaymentPriv.publicKey, paymentHash, feeratePerKw, openingTx.hash, openingTxOut) + assert(swapClaimByCsvTx.txIn.head.sequence == 1008) + assert(checkSpendable(SwapClaimByCsvTx(inputInfo, swapClaimByCsvTx)).isSuccess) + } +} \ No newline at end of file From 584ef481c678f00c206467664c01269e7b9f68c1 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 1 Jul 2022 16:28:50 +0200 Subject: [PATCH 04/23] Add shared peerswap helper functions and data structures --- .../fr/acinq/eclair/swap/SwapCommands.scala | 99 +++++++++++++ .../scala/fr/acinq/eclair/swap/SwapData.scala | 28 ++++ .../fr/acinq/eclair/swap/SwapEvents.scala | 41 ++++++ .../fr/acinq/eclair/swap/SwapHelpers.scala | 134 ++++++++++++++++++ .../fr/acinq/eclair/swap/SwapResponses.scala | 80 +++++++++++ 5 files changed, 382 insertions(+) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala new file mode 100644 index 0000000000..332edd37ec --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala @@ -0,0 +1,99 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor.typed.ActorRef +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpentTriggered, WatchTxConfirmedTriggered} +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} +import fr.acinq.eclair.swap.SwapData._ +import fr.acinq.eclair.swap.SwapResponses.{Response, Status} +import fr.acinq.eclair.wire.protocol.{CancelSwap, HasSwapId, OpeningTxBroadcasted} + +object SwapCommands { + + sealed trait SwapCommand + + // @formatter:off + case class StartSwapInSender(amount: Satoshi, swapId: String, channelId: ByteVector32) extends SwapCommand + case class RestoreSwapInSender(swapData: SwapInSenderData) extends SwapCommand + case object AbortSwapInSender extends SwapCommand + + sealed trait CreateSwapMessages extends SwapCommand + case object StateTimeout extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with ClaimSwapCsvMessages with WaitCsvMessages with SendAgreementMessages with ClaimSwapMessages + case class CancelReceived(cancel: CancelSwap) extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with SendAgreementMessages with AwaitOpeningTxConfirmedMessages + case class ChannelDataFailure(failure: Register.ForwardFailure[CMD_GET_CHANNEL_DATA]) extends CreateSwapMessages + case class ChannelDataResult(channelData: RES_GET_CHANNEL_DATA[ChannelData]) extends CreateSwapMessages + + sealed trait AwaitAgreementMessages extends SwapCommand + + case class SwapMessageReceived(message: HasSwapId) extends AwaitAgreementMessages with AwaitClaimPaymentMessages with SendAgreementMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with ClaimSwapMessages + case class ForwardFailureAdapter(result: Register.ForwardFailure[HasSwapId]) extends AwaitAgreementMessages + + sealed trait CreateOpeningTxMessages extends SwapCommand + case class InvoiceResponse(invoice: Bolt11Invoice) extends CreateOpeningTxMessages + case class OpeningTxFunded(invoice: Bolt11Invoice, fundingResponse: MakeFundingTxResponse) extends CreateOpeningTxMessages + case class OpeningTxCommitted(invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) extends CreateOpeningTxMessages + case class OpeningTxFailed(error: String, fundingResponse_opt: Option[MakeFundingTxResponse] = None) extends CreateOpeningTxMessages + case class RollbackSuccess(error: String, status: Boolean) extends CreateOpeningTxMessages + case class RollbackFailure(error: String, exception: Throwable) extends CreateOpeningTxMessages + + sealed trait AwaitOpeningTxConfirmedMessages extends SwapCommand + case class OpeningTxConfirmed(openingConfirmedTriggered: WatchTxConfirmedTriggered) extends AwaitOpeningTxConfirmedMessages + case object InvoiceExpired extends AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with ClaimSwapCoopMessages + + sealed trait AwaitClaimPaymentMessages extends SwapCommand + case class CsvDelayConfirmed(csvDelayTriggered: WatchTxConfirmedTriggered) extends SwapCommand with WaitCsvMessages + case class PaymentEventReceived(paymentEvent: PaymentEvent) extends AwaitClaimPaymentMessages with PayClaimInvoiceMessages + + sealed trait ClaimSwapCoopMessages extends SwapCommand + case object ClaimTxCommitted extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + case class ClaimTxFailed(error: String) extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + case class ClaimTxInvalid(exception: Throwable) extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + case class ClaimTxConfirmed(claimByCoopConfirmedTriggered: WatchTxConfirmedTriggered) extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + + sealed trait WaitCsvMessages extends SwapCommand + + sealed trait ClaimSwapCsvMessages extends SwapCommand + // @Formatter:on + + // @formatter:off + case object StartSwapInReceiver extends SwapCommand + case class RestoreSwapInReceiver(swapData: SwapInReceiverData) extends SwapCommand + case object AbortSwapInReceiver extends SwapCommand + + sealed trait SendAgreementMessages extends SwapCommand + case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[HasSwapId]) extends SendAgreementMessages with SendCoopCloseMessages + + sealed trait ValidateTxMessages extends SwapCommand + case class ValidInvoice(invoice: Bolt11Invoice) extends ValidateTxMessages + case class InvalidInvoice(reason: String) extends ValidateTxMessages + + sealed trait PayClaimInvoiceMessages extends SwapCommand + + sealed trait SendCoopCloseMessages extends SwapCommand + case class OpeningTxOutputSpent(openingTxOutputSpentTriggered: WatchOutputSpentTriggered) extends SendCoopCloseMessages + + sealed trait ClaimSwapMessages extends SwapCommand + + sealed trait UserMessages extends CreateSwapMessages with SendAgreementMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with SendCoopCloseMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages + case class GetStatus(replyTo: ActorRef[Status]) extends UserMessages + case class CancelRequested(replyTo: ActorRef[Response]) extends UserMessages + // @Formatter:on +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala new file mode 100644 index 0000000000..4920b75f40 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala @@ -0,0 +1,28 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} + +object SwapData { + + final case class SwapInSenderData(channelId: ByteVector32, request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) + + final case class SwapInReceiverData(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala new file mode 100644 index 0000000000..164cd99c43 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.bitcoin.scalacompat.Transaction +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.payment.PaymentReceived + +object SwapEvents { + sealed trait SwapEvent + + case class Canceled(swapId: String) extends SwapEvent + case class TransactionPublished(swapId: String, tx: Transaction, desc: String) extends SwapEvent + case class TransactionConfirmed(swapId: String, tx: Transaction) extends SwapEvent + case class ClaimByInvoiceConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent + case class ClaimByCoopOffered(swapId: String, reason: String) extends SwapEvent + + + case class ClaimByInvoicePaid(swapId: String, payment: PaymentReceived) extends SwapEvent + case class ClaimByCoopConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { + override def toString: String = s"swap $swapId claimed by coop: $confirmation" + } + case class ClaimByCsvConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { + override def toString: String = s"swap $swapId claimed by csv: $confirmation" + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala new file mode 100644 index 0000000000..d4b4a66121 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala @@ -0,0 +1,134 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor +import akka.actor.typed.eventstream.EventStream +import akka.actor.typed.scaladsl.adapter.TypedActorRefOps +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpent, WatchOutputSpentTriggered, WatchTxConfirmed, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} +import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapEvents.TransactionPublished +import fr.acinq.eclair.swap.SwapTransactions.makeSwapOpeningTxOut +import fr.acinq.eclair.transactions.Transactions.{TransactionWithInputInfo, checkSpendable} +import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.{NodeParams, ShortChannelId} +import scodec.bits.ByteVector + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.reflect.ClassTag +import scala.util.{Failure, Success} + +object SwapHelpers { + + def queryChannelData(register: actor.ActorRef, channelId: ByteVector32)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.Forward[CMD_GET_CHANNEL_DATA](channelDataFailureAdapter(context), channelId, CMD_GET_CHANNEL_DATA(channelDataResultAdapter(context).toClassic)) + + def channelDataResultAdapter(context: ActorContext[SwapCommand]): ActorRef[RES_GET_CHANNEL_DATA[ChannelData]] = + context.messageAdapter[RES_GET_CHANNEL_DATA[ChannelData]](ChannelDataResult) + + def channelDataFailureAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[CMD_GET_CHANNEL_DATA]] = + context.messageAdapter[Register.ForwardFailure[CMD_GET_CHANNEL_DATA]](ChannelDataFailure) + + def receiveSwapMessage[B <: SwapCommand : ClassTag](context: ActorContext[SwapCommand], stateName: String)(f: B => Behavior[SwapCommand]): Behavior[SwapCommand] = { + + Behaviors.receiveMessage { + case m: B => context.log.debug(s"processing message ${m.getClass.getSimpleName} in ${context.self.toString} at state $stateName") + f(m) + case m => + context.log.error(s"received unhandled message in ${context.self.toString} at state $stateName of ${m.getClass.getSimpleName}") + Behaviors.same + } + } + + def swapInvoiceExpiredTimer(swapId: String): String = "swap-invoice-expired-timer-" + swapId + + def watchForTxConfirmation(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: ByteVector32, minDepth: Long): Unit = + watcher ! WatchTxConfirmed(replyTo, txId, minDepth) + + def watchForOutputSpent(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchOutputSpentTriggered], txId: ByteVector32, outputIndex: Int): Unit = + watcher ! WatchOutputSpent(replyTo, txId, outputIndex, Set()) + + def payInvoice(nodeParams: NodeParams)(paymentInitiator: actor.ActorRef, swapId: String, invoice: Bolt11Invoice): Unit = + paymentInitiator ! SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true) + + def watchForPayment(watch: Boolean)(implicit context: ActorContext[SwapCommand]): Unit = + if (watch) context.system.classicSystem.eventStream.subscribe(paymentEventAdapter(context).toClassic, classOf[PaymentEvent]) + else context.system.classicSystem.eventStream.unsubscribe(paymentEventAdapter(context).toClassic, classOf[PaymentEvent]) + + def paymentEventAdapter(context: ActorContext[SwapCommand]): ActorRef[PaymentEvent] = context.messageAdapter[PaymentEvent](PaymentEventReceived) + + def sendShortId(register: actor.ActorRef, shortChannelId: ShortChannelId)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.ForwardShortId[HasSwapId](forwardShortIdAdapter(context), shortChannelId, message) + + def forwardShortIdAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortIdFailure[HasSwapId]] = + context.messageAdapter[Register.ForwardShortIdFailure[HasSwapId]](ForwardShortIdFailureAdapter) + + def send(register: actor.ActorRef, channelId: ByteVector32)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.Forward(forwardAdapter(context), channelId, message) + + def forwardAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[HasSwapId]] = + context.messageAdapter[Register.ForwardFailure[HasSwapId]](ForwardFailureAdapter) + + def fundOpening(wallet: OnChainWallet, feeRatePerKw: FeeratePerKw)(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand]): Unit = { + // setup conditions satisfied, create the opening tx + val openingTx = makeSwapOpeningTxOut((request.amount + agreement.premium).sat, PublicKey(ByteVector.fromValidHex(request.pubkey)), PublicKey(ByteVector.fromValidHex(agreement.pubkey)), invoice.paymentHash) + // funding successful, commit the opening tx + context.pipeToSelf(wallet.makeFundingTx(openingTx.publicKeyScript, (request.amount + agreement.premium).sat, feeRatePerKw)) { + case Success(r) => OpeningTxFunded(invoice, r) + case Failure(cause) => OpeningTxFailed(s"error while funding swap open tx: $cause") + } + } + + def commitOpening(wallet: OnChainWallet)(swapId: String, invoice: Bolt11Invoice, fundingResponse: MakeFundingTxResponse, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = { + context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, fundingResponse.fundingTx, desc)) + context.pipeToSelf(wallet.commit(fundingResponse.fundingTx)) { + case Success(true) => OpeningTxCommitted(invoice, OpeningTxBroadcasted(swapId, invoice.toString, fundingResponse.fundingTx.txid.toHex, fundingResponse.fundingTxOutputIndex, "")) + case Success(false) => OpeningTxFailed("could not publish swap open tx", Some(fundingResponse)) + case Failure(t) => OpeningTxFailed(s"failed to commit swap open tx, exception: $t", Some(fundingResponse)) + } + } + + def commitClaim(wallet: OnChainWallet)(swapId: String, txInfo: TransactionWithInputInfo, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = + checkSpendable(txInfo) match { + case Success(_) => + // publish claim by coop tx + context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, txInfo.tx, desc)) + context.pipeToSelf(wallet.commit(txInfo.tx)) { + case Success(true) => ClaimTxCommitted + case Success(false) => ClaimTxFailed("could not publish") + case Failure(t) => ClaimTxFailed(s"failed to commit, exception: $t") + } + case Failure(e) => context.self ! ClaimTxInvalid(e) + } + + def rollback(wallet: OnChainWallet)(error: String, tx: Transaction)(implicit context: ActorContext[SwapCommand]): Unit = + context.pipeToSelf(wallet.rollback(tx)) { + case Success(status) => RollbackSuccess(error, status) + case Failure(t) => RollbackFailure(error, t) + } +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala new file mode 100644 index 0000000000..64e821fcbb --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala @@ -0,0 +1,80 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} + +object SwapResponses { + + sealed trait Response { + def swapId: String + } + + sealed trait Success extends Response + + sealed trait Fail extends Response + + sealed trait Error extends Fail + + case class SwapOpened(swapId: String) extends Success { + override def toString: String = s"swap $swapId opened successfully." + } + + case class UserCanceled(swapId: String) extends Fail { + override def toString: String = s"swap $swapId canceled by user." + } + + case class PeerCanceled(swapId: String) extends Fail { + override def toString: String = s"swap $swapId canceled by peer." + } + + case class InvalidMessage(swapId: String, behavior: String, message: HasSwapId) extends Fail { + override def toString: String = s"swap $swapId canceled due to invalid message during $behavior: $message." + } + + case class LocalError(swapId: String, t: Throwable) extends Error { + override def toString: String = s"swap $swapId local error: $t." + } + + case class SwapError(swapId: String, reason: String) extends Error { + override def toString: String = s"swap $swapId swap error: $reason." + } + + case class InsufficientBalanceForReceive(swapId: String, amount: Satoshi, availableForReceive: Satoshi) extends Error { + override def toString: String = s"swap $swapId error: requested amount of $amount sat > available channel balance to receive of $availableForReceive sat." + } + + case class InsufficientBalanceForSend(swapId: String, amount: Satoshi, availableForSend: Satoshi) extends Error { + override def toString: String = s"swap $swapId error: requested amount of $amount sat > available channel balance to send of $availableForSend sat." + } + + case class InsufficientOnChainBalance(swapId: String, amount: Satoshi, maxPremium: Satoshi, onChainBalance: Satoshi) extends Error { + override def toString: String = s"swap $swapId error: requested amount of $amount + $maxPremium maximum premium > confirmed on-chain balance of $onChainBalance." + } + + case class InternalError(swapId: String, reason: String) extends Error { + override def toString: String = s"swap $swapId internal error: $reason." + } + + sealed trait Status extends Response + case class SwapInStatus(swapId: String, actor: String, behavior: String, channelId: ByteVector32, request: SwapInRequest, agreement_opt: Option[SwapInAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None) extends Status { + override def toString: String = s"$actor[$behavior]: $swapId, $channelId, $request, $agreement_opt, $invoice_opt, $openingTxBroadcasted_opt" + } + +} From ed0b0f824d7face67f92bc6e5b9d3c51076b5643 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 21 Jun 2022 15:03:10 +0200 Subject: [PATCH 05/23] Add new SwapKeyManager for peerswap swaps --- .../scala/fr/acinq/eclair/NodeParams.scala | 9 +- .../main/scala/fr/acinq/eclair/Setup.scala | 8 +- .../eclair/swap/LocalSwapKeyManager.scala | 84 +++++++++++++++++++ .../fr/acinq/eclair/swap/SwapKeyManager.scala | 57 +++++++++++++ .../scala/fr/acinq/eclair/StartupSpec.scala | 4 +- .../scala/fr/acinq/eclair/TestConstants.scala | 5 ++ .../LocalChannelKeyManagerSpec.scala | 7 +- .../keymanager/LocalNodeKeyManagerSpec.scala | 8 +- .../basic/fixtures/MinimalNodeFixture.scala | 2 + 9 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/LocalSwapKeyManager.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapKeyManager.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index e97bac0a8e..71662eef79 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -35,6 +35,7 @@ import fr.acinq.eclair.router.Announcements.AddressException import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios} import fr.acinq.eclair.router.PathFindingExperimentConf import fr.acinq.eclair.router.Router.{MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries} +import fr.acinq.eclair.swap.SwapKeyManager import fr.acinq.eclair.tor.Socks5ProxyParams import fr.acinq.eclair.wire.protocol._ import grizzled.slf4j.Logging @@ -54,6 +55,7 @@ import scala.jdk.CollectionConverters._ */ case class NodeParams(nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, + swapKeyManager: SwapKeyManager, instanceId: UUID, // a unique instance ID regenerated after each restart private val blockHeight: AtomicLong, alias: String, @@ -140,6 +142,7 @@ object NodeParams extends Logging { val oldSeedPath = new File(datadir, "seed.dat") val nodeSeedFilename: String = "node_seed.dat" val channelSeedFilename: String = "channel_seed.dat" + val swapSeedFilename: String = "swap_seed.dat" def getSeed(filename: String): ByteVector = { val seedPath = new File(datadir, filename) @@ -157,7 +160,8 @@ object NodeParams extends Logging { val nodeSeed = getSeed(nodeSeedFilename) val channelSeed = getSeed(channelSeedFilename) - Seeds(nodeSeed, channelSeed) + val swapSeed = getSeed(swapSeedFilename) + Seeds(nodeSeed, channelSeed, swapSeed) } private val chain2Hash: Map[String, ByteVector32] = Map( @@ -188,7 +192,7 @@ object NodeParams extends Logging { } } - def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, + def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, swapKeyManager: SwapKeyManager, torAddress_opt: Option[NodeAddress], database: Databases, blockHeight: AtomicLong, feeEstimator: FeeEstimator, pluginParams: Seq[PluginParams] = Nil): NodeParams = { // check configuration for keys that have been renamed @@ -422,6 +426,7 @@ object NodeParams extends Logging { NodeParams( nodeKeyManager = nodeKeyManager, channelKeyManager = channelKeyManager, + swapKeyManager = swapKeyManager, instanceId = instanceId, blockHeight = blockHeight, alias = nodeAlias, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index ccae994a45..caaf434ac3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -43,6 +43,7 @@ import fr.acinq.eclair.payment.receive.PaymentHandler import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator} import fr.acinq.eclair.router._ +import fr.acinq.eclair.swap.LocalSwapKeyManager import fr.acinq.eclair.tor.{Controller, TorProtocolHandler} import fr.acinq.eclair.wire.protocol.NodeAddress import grizzled.slf4j.Logging @@ -93,12 +94,13 @@ class Setup(val datadir: File, datadir.mkdirs() val config = system.settings.config.getConfig("eclair") - val Seeds(nodeSeed, channelSeed) = seeds_opt.getOrElse(NodeParams.getSeeds(datadir)) + val Seeds(nodeSeed, channelSeed, swapSeed) = seeds_opt.getOrElse(NodeParams.getSeeds(datadir)) val chain = config.getString("chain") val chaindir = new File(datadir, chain) chaindir.mkdirs() val nodeKeyManager = new LocalNodeKeyManager(nodeSeed, NodeParams.hashFromChain(chain)) val channelKeyManager = new LocalChannelKeyManager(channelSeed, NodeParams.hashFromChain(chain)) + val swapKeyManager = new LocalSwapKeyManager(swapSeed, NodeParams.hashFromChain(chain)) val instanceId = UUID.randomUUID() logger.info(s"instanceid=$instanceId") @@ -132,7 +134,7 @@ class Setup(val datadir: File, // @formatter:on } - val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, initTor(), databases, blockHeight, feeEstimator, pluginParams) + val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, swapKeyManager, initTor(), databases, blockHeight, feeEstimator, pluginParams) pluginParams.foreach(param => logger.info(s"using plugin=${param.name}")) val serverBindingAddress = new InetSocketAddress(config.getString("server.binding-ip"), config.getInt("server.port")) @@ -380,7 +382,7 @@ class Setup(val datadir: File, object Setup { - final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector) + final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector, swapSeed: ByteVector) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/LocalSwapKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/LocalSwapKeyManager.scala new file mode 100644 index 0000000000..d226b5dee9 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/LocalSwapKeyManager.scala @@ -0,0 +1,84 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache} +import fr.acinq.bitcoin.scalacompat.DeterministicWallet._ +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, DeterministicWallet} +import fr.acinq.eclair.KamonExt +import fr.acinq.eclair.crypto.Monitoring.{Metrics, Tags} +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, TransactionWithInputInfo, TxOwner} +import grizzled.slf4j.Logging +import kamon.tag.TagSet +import scodec.bits.ByteVector + +// TODO: move shared functionality in ChannelKeyManager to new parent KeyManager and derive SwapKeyManager and ChannelKeyManager from KeyManager? + +object LocalSwapKeyManager { + def keyBasePath(chainHash: ByteVector32): List[Long] = (chainHash: @unchecked) match { + case Block.RegtestGenesisBlock.hash | Block.TestnetGenesisBlock.hash | Block.SignetGenesisBlock.hash => DeterministicWallet.hardened(46) :: DeterministicWallet.hardened(1) :: Nil + case Block.LivenetGenesisBlock.hash => DeterministicWallet.hardened(47) :: DeterministicWallet.hardened(1) :: Nil + } +} + +/** + * This class manages swap secrets and private keys. + * It exports points and public keys, and provides signing methods + * + * @param seed seed from which the swap keys will be derived + */ +class LocalSwapKeyManager(seed: ByteVector, chainHash: ByteVector32) extends SwapKeyManager with Logging { + private val master = DeterministicWallet.generate(seed) + + private val privateKeys: LoadingCache[KeyPath, ExtendedPrivateKey] = CacheBuilder.newBuilder() + .maximumSize(200) // 1 key per party per swap * 200 swaps + .build[KeyPath, ExtendedPrivateKey](new CacheLoader[KeyPath, ExtendedPrivateKey] { + override def load(keyPath: KeyPath): ExtendedPrivateKey = derivePrivateKey(master, keyPath) + }) + + private val publicKeys: LoadingCache[KeyPath, ExtendedPublicKey] = CacheBuilder.newBuilder() + .maximumSize(200) // 1 key per party per swap * 200 swaps + .build[KeyPath, ExtendedPublicKey](new CacheLoader[KeyPath, ExtendedPublicKey] { + override def load(keyPath: KeyPath): ExtendedPublicKey = publicKey(privateKeys.get(keyPath)) + }) + + private def internalKeyPath(swapKeyPath: DeterministicWallet.KeyPath, index: Long): KeyPath = KeyPath((LocalSwapKeyManager.keyBasePath(chainHash) ++ swapKeyPath.path) :+ index) + + override def openingPrivateKey(swapKeyPath: DeterministicWallet.KeyPath): ExtendedPrivateKey = privateKeys.get(internalKeyPath(swapKeyPath, hardened(0))) + + override def openingPublicKey(swapKeyPath: DeterministicWallet.KeyPath): ExtendedPublicKey = publicKeys.get(internalKeyPath(swapKeyPath, hardened(0))) + + + /** + * @param tx input transaction + * @param publicKey extended public key + * @param txOwner owner of the transaction (local/remote) + * @param commitmentFormat format of the commitment tx + * @return a signature generated with the private key that matches the input extended public key + */ + override def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): ByteVector64 = { + // NB: not all those transactions are actually commit txs (especially during closing), but this is good enough for monitoring purposes + val tags = TagSet.Empty.withTag(Tags.TxOwner, txOwner.toString).withTag(Tags.TxType, Tags.TxTypes.CommitTx) + Metrics.SignTxCount.withTags(tags).increment() + KamonExt.time(Metrics.SignTxDuration.withTags(tags)) { + val privateKey = privateKeys.get(publicKey.path) + Transactions.sign(tx, privateKey.privateKey, txOwner, commitmentFormat) + } + } + +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapKeyManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapKeyManager.scala new file mode 100644 index 0000000000..87b6738721 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapKeyManager.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import fr.acinq.bitcoin.scalacompat.DeterministicWallet.{ExtendedPrivateKey, ExtendedPublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector64, DeterministicWallet, Protocol} +import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, TransactionWithInputInfo, TxOwner} +import scodec.bits.ByteVector + +import java.io.ByteArrayInputStream +import java.nio.ByteOrder + +trait SwapKeyManager { + def openingPublicKey(keyPath: DeterministicWallet.KeyPath): ExtendedPublicKey + def openingPrivateKey(keyPath: DeterministicWallet.KeyPath): ExtendedPrivateKey + + /** + * @param tx input transaction + * @param publicKey extended public key + * @param txOwner owner of the transaction (local/remote) + * @param commitmentFormat format of the commitment tx + * @return a signature generated with the private key that matches the input extended public key + */ + def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): ByteVector64 +} + +object SwapKeyManager { + /** + * Create a BIP32 path from a public key. This path will be used to derive swap keys. + * TODO: rethink the workflow for key derivation for swaps + * + * @param swapId ID of the swap + * @return a BIP32 path + */ + def keyPath(swapId: String): DeterministicWallet.KeyPath = { + val bis = new ByteArrayInputStream(ByteVector.fromValidHex(swapId).toArray) + + def next(): Long = Protocol.uint32(bis, ByteOrder.BIG_ENDIAN) + + DeterministicWallet.KeyPath(Seq(next(), next(), next(), next(), next(), next(), next(), next())) + } +} + diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index 8e5a25e74f..c366de148e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -23,6 +23,7 @@ import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features._ import fr.acinq.eclair.blockchain.fee.{DustTolerance, FeeratePerByte, FeeratePerKw, FeerateTolerance} import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager} +import fr.acinq.eclair.swap.LocalSwapKeyManager import org.scalatest.funsuite.AnyFunSuite import scodec.bits.{ByteVector, HexStringSyntax} @@ -39,9 +40,10 @@ class StartupSpec extends AnyFunSuite { val blockCount = new AtomicLong(0) val nodeKeyManager = new LocalNodeKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) + val swapKeyManager = new LocalSwapKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val feeEstimator = new TestFeeEstimator() val db = TestDatabases.inMemoryDb() - NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, db, blockCount, feeEstimator) + NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, swapKeyManager, None, db, blockCount, feeEstimator) } test("check configuration") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 1b027c2137..a5bc68b32b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -30,6 +30,7 @@ import fr.acinq.eclair.payment.relay.Relayer.{RelayFees, RelayParams} import fr.acinq.eclair.router.Graph.WeightRatios import fr.acinq.eclair.router.PathFindingExperimentConf import fr.acinq.eclair.router.Router.{MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries} +import fr.acinq.eclair.swap.LocalSwapKeyManager import fr.acinq.eclair.wire.protocol.{Color, EncodingType, NodeAddress, OnionRoutingPacket} import org.scalatest.Tag import scodec.bits.{ByteVector, HexStringSyntax} @@ -75,11 +76,13 @@ object TestConstants { val seed: ByteVector32 = ByteVector32(hex"b4acd47335b25ab7b84b8c020997b12018592bb4631b868762154d77fa8b93a3") // 02aaaa... val nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash) + val swapKeyManager = new LocalSwapKeyManager(seed, Block.RegtestGenesisBlock.hash) // This is a function, and not a val! When called will return a new NodeParams def nodeParams: NodeParams = NodeParams( nodeKeyManager, channelKeyManager, + swapKeyManager, blockHeight = new AtomicLong(defaultBlockHeight), alias = "alice", color = Color(1, 2, 3), @@ -221,10 +224,12 @@ object TestConstants { val seed: ByteVector32 = ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492") // 02bbbb... val nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash) + val swapKeyManager = new LocalSwapKeyManager(seed, Block.RegtestGenesisBlock.hash) def nodeParams: NodeParams = NodeParams( nodeKeyManager, channelKeyManager, + swapKeyManager, blockHeight = new AtomicLong(defaultBlockHeight), alias = "bob", color = Color(4, 5, 6), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala index 57cc1b0df7..ca1afffcf2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala @@ -16,8 +16,6 @@ package fr.acinq.eclair.crypto.keymanager -import java.io.File -import java.nio.file.Files import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, DeterministicWallet} @@ -28,6 +26,9 @@ import fr.acinq.eclair.{NodeParams, TestConstants, TestUtils} import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ +import java.io.File +import java.nio.file.Files + class LocalChannelKeyManagerSpec extends AnyFunSuite { test("generate the same secrets from the same seed") { @@ -133,7 +134,7 @@ class LocalChannelKeyManagerSpec extends AnyFunSuite { val seed = hex"17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501" val seedDatFile = TestUtils.createSeedFile("seed.dat", seed.toArray) - val Seeds(_, _) = NodeParams.getSeeds(seedDatFile.getParentFile) + val Seeds(_, _, _) = NodeParams.getSeeds(seedDatFile.getParentFile) val channelSeedDatFile = new File(seedDatFile.getParentFile, "channel_seed.dat") assert(channelSeedDatFile.exists()) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala index 3b33f54809..f1051b6189 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala @@ -16,9 +16,6 @@ package fr.acinq.eclair.crypto.keymanager -import java.io.File -import java.nio.file.Files - import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto} @@ -27,6 +24,9 @@ import fr.acinq.eclair.{NodeParams, TestUtils} import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ +import java.io.File +import java.nio.file.Files + class LocalNodeKeyManagerSpec extends AnyFunSuite { test("generate the same node id from the same seed") { @@ -53,7 +53,7 @@ class LocalNodeKeyManagerSpec extends AnyFunSuite { val seed = hex"17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501" val seedDatFile = TestUtils.createSeedFile("seed.dat", seed.toArray) - val Seeds(_, _) = NodeParams.getSeeds(seedDatFile.getParentFile) + val Seeds(_, _, _) = NodeParams.getSeeds(seedDatFile.getParentFile) val nodeSeedDatFile = new File(seedDatFile.getParentFile, "node_seed.dat") assert(nodeSeedDatFile.exists()) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index da1c24e8b7..68a78494c6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -24,6 +24,7 @@ import fr.acinq.eclair.payment.receive.{MultiPartHandler, PaymentHandler} import fr.acinq.eclair.payment.relay.{ChannelRelayer, Relayer} import fr.acinq.eclair.payment.send.PaymentInitiator import fr.acinq.eclair.router.Router +import fr.acinq.eclair.swap.LocalSwapKeyManager import fr.acinq.eclair.wire.protocol.IPAddress import fr.acinq.eclair.{BlockHeight, MilliSatoshi, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases, TestFeeEstimator} import org.scalatest.concurrent.PatienceConfiguration @@ -61,6 +62,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat instanceId = UUID.randomUUID(), nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash), channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash), + swapKeyManager = new LocalSwapKeyManager(seed, Block.RegtestGenesisBlock.hash), torAddress_opt = None, database = TestDatabases.inMemoryDb(), blockHeight = new AtomicLong(400_000), From 559a2da1d06780847086bcea10b1f7e076480dfd Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 21 Jun 2022 15:43:02 +0200 Subject: [PATCH 06/23] Add SwapInSender peerswap workflow actor --- .../fr/acinq/eclair/swap/SwapInSender.scala | 320 ++++++++++++++++++ .../acinq/eclair/swap/SwapInSenderSpec.scala | 251 ++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala new file mode 100644 index 0000000000..f3eab33612 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala @@ -0,0 +1,320 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor +import akka.actor.typed.eventstream.EventStream.Publish +import akka.actor.typed.scaladsl.adapter.TypedActorRefOps +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import akka.util.Timeout +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.{DATA_NORMAL, RES_GET_CHANNEL_DATA} +import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceivePayment} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapEvents._ +import fr.acinq.eclair.swap.SwapHelpers._ +import fr.acinq.eclair.swap.SwapResponses.{Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapInStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByCoopTx, makeSwapClaimByCsvTx, makeSwapOpeningInputInfo} +import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx} +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{NodeParams, TimestampSecond} +import scodec.bits.ByteVector + +import scala.concurrent.duration.DurationInt + +object SwapInSender { + /* + SwapInSender SwapInReceiver + + INITIATOR RESPONDER + [createSwap] | | + | SwapInRequest | + |------------------------------->| + [awaitAgreement] | | [validateRequest] + | | + | SwapInAgreement | [sendAgreement] + |<-------------------------------| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Claim With Preimage" + [awaitClaimPayment] | | [validateOpeningTx] + | | + | | [payClaimInvoice] + |<------------------------------>| + | | [claimSwap] (claim_by_invoice) + + "Refund Cooperatively" + | CoopClose | [sendCoopClose] + |<-------------------------------| + (claim_by_coop) [claimSwapCoop] | | + + "Refund After Csv" + [waitCsv] | | + | | + (claim_by_csv) [claimSwapCsv] | | + + */ + + def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet): Behavior[SwapCommands.SwapCommand] = + Behaviors.setup { context => + Behaviors.receiveMessagePartial { + case StartSwapInSender(amount, swapId, channelId) => + new SwapInSender(amount, swapId, channelId, nodeParams, watcher, register, wallet, context) + .createSwap() + case RestoreSwapInSender(d) => + new SwapInSender(d.request.amount.sat, d.request.swapId, d.channelId, nodeParams, watcher, register, wallet, context) + .awaitOpeningTxConfirmed(d.request, d.agreement, d.invoice, d.openingTxBroadcasted) + case AbortSwapInSender => Behaviors.stopped + } + } +} + +private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVector32, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { + val protocolVersion = 1 + val noAsset = "" + implicit val timeout: Timeout = 30 seconds + private val keyManager: SwapKeyManager = nodeParams.swapKeyManager + private implicit val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + private val maxPremium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap sender calculate an acceptable premium? + + private def makerPrivkey(): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + private def makerPubkey(): PublicKey = makerPrivkey().publicKey + private def takerPubkey(agreement: SwapInAgreement): PublicKey = PublicKey(ByteVector.fromValidHex(agreement.pubkey)) + + private def createSwap(): Behavior[SwapCommand] = { + // a finalized scid must exist for the channel to create a swap + queryChannelData(register, channelId) + receiveSwapMessage[CreateSwapMessages](context, "createSwap") { + case ChannelDataFailure(e) => swapCanceled(InternalError(swapId, s"channel data query failure: ${e.fwd}.")) + case ChannelDataResult(RES_GET_CHANNEL_DATA(channelData)) if channelData.isInstanceOf[DATA_NORMAL] => + val shortChannelId = channelData.asInstanceOf[DATA_NORMAL].shortIds.real.toOption.get.toString + awaitAgreement(SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId, amount.toLong, makerPubkey().toHex)) + case ChannelDataResult(channelData) => swapCanceled(InternalError(swapId, s"invalid channel: $channelData.")) + case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) + case CancelReceived(_) => Behaviors.same + case StateTimeout => swapCanceled(InternalError(swapId, "timeout during createSwap")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) + swapCanceled(UserCanceled(swapId)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "createSwap", channelId, SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), "unknown", amount.toLong, makerPubkey().toHex)) + Behaviors.same + } + } + + private def awaitAgreement(request: SwapInRequest): Behavior[SwapCommand] = { + send(register, channelId)(request) + + receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { + case SwapMessageReceived(agreement: SwapInAgreement) if agreement.protocolVersion != protocolVersion => + swapCanceled(InternalError(swapId, s"protocol version must be $protocolVersion.")) + case SwapMessageReceived(agreement: SwapInAgreement) if agreement.premium > maxPremium => + swapCanceled(InternalError(swapId, "unacceptable premium requested.")) + case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement) + case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) + case CancelReceived(_) => Behaviors.same + case StateTimeout => swapCanceled(InternalError(swapId, "timeout during awaitAgreement")) + case ForwardFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap request to peer.")) + case SwapMessageReceived(m) => swapCanceled(InvalidMessage(swapId, "awaitAgreement", m)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) + swapCanceled(UserCanceled(swapId)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitAgreement", channelId, request) + Behaviors.same + } + } + + def createOpeningTx(request: SwapInRequest, agreement: SwapInAgreement): Behavior[SwapCommand] = { + val receivePayment = ReceivePayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left("send-swap-in")) + val createInvoice = context.spawnAnonymous(CreateInvoiceActor(nodeParams)) + createInvoice ! CreateInvoiceActor.CreateInvoice(context.messageAdapter[Bolt11Invoice](InvoiceResponse).toClassic, receivePayment) + + receiveSwapMessage[CreateOpeningTxMessages](context, "createOpeningTx") { + case InvoiceResponse(invoice: Bolt11Invoice) => fundOpening(wallet, feeRatePerKw)(request, agreement, invoice) + Behaviors.same + // TODO: checkpoint PersistentSwapData for this swap to a database before committing the opening tx + case OpeningTxFunded(invoice, fundingResponse) => commitOpening(wallet)(swapId, invoice, fundingResponse, "swap-in-sender-opening") + Behaviors.same + case OpeningTxCommitted(invoice, openingTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, invoice, openingTxBroadcasted) + case OpeningTxFailed(error, None) => swapCanceled(InternalError(swapId, s"failed to fund swap open tx, error: $error")) + case OpeningTxFailed(error, Some(r)) => rollback(wallet)(error, r.fundingTx) + Behaviors.same + case RollbackSuccess(error, value) => swapCanceled(InternalError(swapId, s"rollback: Success($value), error: $error")) + case RollbackFailure(error, t) => swapCanceled(InternalError(swapId, s"rollback exception: $t, error: $error")) + case CancelReceived(_) => Behaviors.same // ignore + case StateTimeout => + // TODO: are we sure the opening transaction has not yet been committed? should we rollback locked funding outputs? + swapCanceled(InternalError(swapId, "timeout during CreateOpeningTx")) + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same // ignore + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "createOpeningTx", channelId, request, Some(agreement)) + Behaviors.same + } + } + + def awaitOpeningTxConfirmed(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) + watchForTxConfirmation(watcher)(openingConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), nodeParams.channelConf.minDepthBlocks) // watch for opening tx to be confirmed + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapInvoiceExpiredTimer(swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { + case OpeningTxConfirmed(_) => + awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted) + case SwapMessageReceived(coopClose: CoopClose) if coopClose.swapId == swapId => + claimSwapCoop(request, agreement, invoice, openingTxBroadcasted, coopClose) + case SwapMessageReceived(_) => Behaviors.same + case CancelReceived(c) if c.swapId == swapId => waitCsv(request, agreement, invoice, openingTxBroadcasted) + case CancelReceived(_) => Behaviors.same + case InvoiceExpired => + waitCsv(request, agreement, invoice, openingTxBroadcasted) + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitOpeningTxConfirmed", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + } + + def awaitClaimPayment(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + // TODO: query payment database for received payment + watchForPayment(watch = true) // subscribe to be notified of payment events + send(register, channelId)(openingTxBroadcasted) // send message to peer about opening tx broadcast + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapInvoiceExpiredTimer(swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { + case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= request.amount.sat && payment.parts.forall(p => p.fromChannelId == channelId) => + swapCompleted(ClaimByInvoicePaid(swapId, payment)) + case SwapMessageReceived(coopClose: CoopClose) if coopClose.swapId == swapId => + claimSwapCoop(request, agreement, invoice, openingTxBroadcasted, coopClose) + case PaymentEventReceived(_) => Behaviors.same + case SwapMessageReceived(_) => Behaviors.same + case InvoiceExpired => + waitCsv(request, agreement, invoice, openingTxBroadcasted) + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitClaimPayment", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + } + + def claimSwapCoop(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, coopClose: CoopClose): Behavior[SwapCommand] = { + val takerPrivkey = PrivateKey(ByteVector.fromValidHex(coopClose.privkey)) + val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) + val claimByCoopTx = makeSwapClaimByCoopTx(request.amount.sat, makerPrivkey(), takerPrivkey, invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat, makerPubkey(), takerPrivkey.publicKey, invoice.paymentHash) + def claimByCoopConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) + + watchForPayment(watch = false) + commitClaim(wallet)(swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapInvoiceExpiredTimer(swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { + case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) + Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByCoopConfirmed(swapId, confirmedTriggered)) + case ClaimTxFailed(error) => context.log.error(s"swap $swapId coop claim tx failed, error: $error") + waitCsv(request, agreement, invoice, openingTxBroadcasted) + case ClaimTxInvalid(e) => context.log.error(s"swap $swapId coop claim tx is invalid: $e, tx: $claimByCoopTx") + waitCsv(request, agreement, invoice, openingTxBroadcasted) + case InvoiceExpired => + waitCsv(request, agreement, invoice, openingTxBroadcasted) + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwapCoop", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + } + + def waitCsv(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + // TODO: are we sure the opening transaction has been committed? should we rollback locked funding outputs? + def csvDelayConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](CsvDelayConfirmed) + watchForTxConfirmation(watcher)(csvDelayConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), claimByCsvDelta.toInt) // watch for opening tx to be buried enough that it can be claimed by csv + + receiveSwapMessage[WaitCsvMessages](context, "waitCsv") { + case CsvDelayConfirmed(_) => + claimSwapCsv(request, agreement, invoice, openingTxBroadcasted) + case StateTimeout => + // TODO: problem with the blockchain monitor? + Behaviors.same + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "waitCsv", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def claimSwapCsv(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) + val claimByCsvTx = makeSwapClaimByCsvTx(request.amount.sat, makerPrivkey(), takerPubkey(agreement), invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat, makerPubkey(), takerPubkey(agreement), invoice.paymentHash) + def claimByCsvConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) + + watchForPayment(watch = false) + commitClaim(wallet)(swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "swap-in-sender-claimByCsvTx") + + receiveSwapMessage[ClaimSwapCsvMessages](context, "claimSwapCsv") { + case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCsvConfirmedAdapter, claimByCsvTx.txid, nodeParams.channelConf.minDepthBlocks) + Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByCsvConfirmed(swapId, confirmedTriggered)) + case ClaimTxFailed(error) => context.log.error(s"swap $swapId csv claim tx failed, error: $error") + waitCsv(request, agreement, invoice, openingTxBroadcasted) + case ClaimTxInvalid(e) => context.log.error(s"swap $swapId csv claim tx is invalid: $e, tx: $claimByCsvTx") + waitCsv(request, agreement, invoice, openingTxBroadcasted) + case StateTimeout => + // TODO: handle when claim tx not confirmed, resubmit the tx? + Behaviors.same + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwapCsv", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(event) + context.log.info(s"completed swap: $event.") + Behaviors.stopped + } + + def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(Canceled(swapId)) + if (!failure.isInstanceOf[PeerCanceled]) send(register, channelId)(CancelSwap(swapId, failure.toString)) + failure match { + case e: Error => context.log.error(s"canceled swap: $e") + case f: Fail => context.log.info(s"canceled swap: $f") + case _ => context.log.error(s"canceled swap $swapId, reason: unknown.") + } + Behaviors.stopped + } + +} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala new file mode 100644 index 0000000000..575a567b48 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala @@ -0,0 +1,251 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.Register.Forward +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, DATA_NORMAL, RES_GET_CHANNEL_DATA} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapData.SwapInSenderData +import fr.acinq.eclair.swap.SwapEvents._ +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.{CoopClose, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} + +// with BitcoindService +case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 1 + val noAsset = "" + val network: String = Block.RegtestGenesisBlock.hash.toString() + val amount: Satoshi = 1000 sat + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val makerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val takerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val makerPubkey: PublicKey = makerPrivkey.publicKey + val takerPubkey: PublicKey = takerPrivkey.publicKey + val premium = 10 + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + val agreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId, makerPubkey.toHex, premium) + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + val wallet = new DummyOnChainWallet() { + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(6930 sat, 0 sat)) + } + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + + // subscribe to notification events from SwapInSender when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInSender(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + + withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path from restored swap") { f => + import f._ + + // restore the SwapInSender actor state from a confirmed on-chain opening tx + val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) + swapInSender ! RestoreSwapInSender(swapData) + + // SwapInSender confirms opening tx on-chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) + + // resend OpeningTxBroadcasted when swap restored + register.expectMessageType[Forward[OpeningTxBroadcasted]] + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // subscribe to notification when SwapInSender successfully receives payment + val paymentEvent = testKit.createTestProbe[PaymentReceived]() + testKit.system.eventStream ! Subscribe(paymentEvent.ref) + + // SwapInSender receives a payment with the corresponding payment hash + val paymentReceived = PaymentReceived(invoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapInSender reports a successful payment + paymentEvent.expectMessageType[PaymentReceived] + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByInvoicePaid] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInSender) + } + + test("happy path for new swap") { f => + import f._ + + // start new SwapInSender + swapInSender ! StartSwapInSender(amount, swapId, channelId) + + // SwapInSender will first request channel data to get shortChannelId + val getChannelData = register.expectMessageType[Forward[CMD_GET_CHANNEL_DATA]] + getChannelData.replyTo.toClassic ! RES_GET_CHANNEL_DATA(channelData) + + // SwapInSender: SwapInRequest -> SwapInSender + val swapInRequest = register.expectMessageType[Forward[SwapInRequest]] + + // SwapInReceiver: SwapInAgreement -> SwapInSender + swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.message.protocolVersion, swapInRequest.message.swapId, takerPubkey.toString(), premium)) + + // SwapInSender confirms opening tx on-chain + val openingTx = swapEvents.expectMessageType[TransactionPublished].tx + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx) + + // SwapInSender reports status of awaiting payment + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitClaimPayment") + + // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver + val openingTxBroadcasted = register.expectMessageType[Forward[OpeningTxBroadcasted]] + val invoice = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInSender receives a payment with the corresponding payment hash + // TODO: convert from ShortChannelId to ByteVector32 + val paymentReceived = PaymentReceived(invoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByInvoicePaid] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } + + test("claim refund by coop close path from restored swap") { f => + import f._ + + // restore the SwapInSender actor state from a confirmed on-chain opening tx + val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) + swapInSender ! RestoreSwapInSender(swapData) + + // SwapInSender confirms opening tx on-chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) + + // resend OpeningTxBroadcasted when swap restored + register.expectMessageType[Forward[OpeningTxBroadcasted]] + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver: CoopClose -> SwapInSender + swapInSender ! SwapMessageReceived(CoopClose(swapId, "oops", takerPrivkey.toHex)) + watcher.expectMessageType[WatchTxConfirmed] + + // SwapInSender reports status of awaiting claim by cooperative close tx to confirm + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapInStatus].behavior == "claimSwapCoop") + + // ZmqWatcher -> SwapInSender, trigger confirmation of coop close transaction + swapEvents.expectMessageType[TransactionPublished] + swapInSender ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0))) + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByCoopConfirmed] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } + + test("claim refund by csv path from restored swap") { f => + import f._ + + // restore the SwapInSender actor state from a confirmed on-chain opening tx + val invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice with short expiry"), CltvExpiryDelta(18), + expirySeconds = Some(2)) + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) + swapInSender ! RestoreSwapInSender(swapData) + + // watch for and trigger that the opening tx has been confirmed on-chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(0), 0, Transaction(2, Seq(), Seq(), 0)) + + // resend OpeningTxBroadcasted when swap restored + register.expectMessageType[Forward[OpeningTxBroadcasted]] + + // wait to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // watch for and trigger that the opening tx has been buried by csv delay blocks + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(0), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0)) + + // SwapInSender reports status of awaiting claim by csv tx to confirm + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapInStatus].behavior == "claimSwapCsv") + + // watch for and trigger that the claim-by-csv tx has been confirmed on chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(0), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0)) + + // SwapInSender reports a successful csv close + swapEvents.expectMessageType[TransactionPublished] + swapEvents.expectMessageType[ClaimByCsvConfirmed] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } + +} From 2e17561f577c0bde85887fb2b21a4c90a4b97d94 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 1 Jul 2022 16:31:19 +0200 Subject: [PATCH 07/23] Add SwapInReceiver peerswap workflow actor --- .../fr/acinq/eclair/swap/SwapInReceiver.scala | 264 ++++++++++++++++++ .../eclair/swap/SwapInReceiverSpec.scala | 204 ++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala new file mode 100644 index 0000000000..7419a91c89 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala @@ -0,0 +1,264 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor +import akka.actor.typed.eventstream.EventStream.Publish +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import akka.util.Timeout +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpentTriggered, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentFailed, PaymentSent} +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapEvents._ +import fr.acinq.eclair.swap.SwapHelpers._ +import fr.acinq.eclair.swap.SwapResponses.{Error, Fail, InternalError, PeerCanceled, SwapError, SwapInStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningInputInfo, validOpeningTx} +import fr.acinq.eclair.transactions.Transactions.SwapClaimByCoopTx +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{NodeParams, ShortChannelId, ToMilliSatoshiConversion} +import scodec.bits.ByteVector + +import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Success} + +object SwapInReceiver { + /* + SwapInSender SwapInReceiver + + INITIATOR RESPONDER + [createSwap] | | + | SwapInRequest | + |------------------------------->| + [awaitAgreement] | | [validateRequest] + | | + | SwapInAgreement | [sendAgreement] + |<-------------------------------| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Claim With Preimage" + [awaitClaimPayment] | | [validateOpeningTx] + | | + | | [payClaimInvoice] + |<------------------------------>| + | | [claimSwap] (claim_by_invoice) + + "Refund Cooperatively" + | CoopClose | [sendCoopClose] + |<-------------------------------| + (claim_by_coop) [claimSwapCoop] | | + + "Refund After Csv" + [waitCsv] | | + | | + (claim_by_csv) [claimSwapCsv] | | + + */ + + def apply(request: SwapInRequest, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet): Behavior[SwapCommand] = + Behaviors.setup { context => + Behaviors.receiveMessagePartial { + case StartSwapInReceiver => + ShortChannelId.fromCoordinates(request.scid) match { + case Success(shortChannelId) => new SwapInReceiver(request, shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + .validateRequest() + case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") + Behaviors.stopped + } + case RestoreSwapInReceiver(d) => + ShortChannelId.fromCoordinates(d.request.scid) match { + case Success(shortChannelId) => new SwapInReceiver(d.request, shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + .awaitOpeningTxConfirmed(d.agreement, d.openingTxBroadcasted) + case Failure(e) => context.log.error(s"could not restore swap request with invalid shortChannelId: $request, $e") + Behaviors.stopped + } + case AbortSwapInReceiver => Behaviors.stopped + } + } +} + +private class SwapInReceiver(request: SwapInRequest, shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { + val protocolVersion = 1 + val noAsset = "" + implicit val timeout: Timeout = 30 seconds + + private val keyManager: SwapKeyManager = nodeParams.swapKeyManager + private val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + private val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap receiver calculate an acceptable premium? + private val swapId: String = request.swapId + private val takerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + private val takerPubkey: PublicKey = takerPrivkey.publicKey + private val makerPubkey: PublicKey = PublicKey(ByteVector.fromValidHex(request.pubkey)) + + private def validateRequest(): Behavior[SwapCommand] = { + // fail if swap request is invalid, otherwise respond with agreement + if (request.protocolVersion != protocolVersion || request.asset != noAsset || request.network != NodeParams.chainFromHash(nodeParams.chainHash)) { + swapCanceled(InternalError(swapId, s"swap $swapId incompatible request: $request.")) + } else { + sendAgreement(SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium)) + } + } + + private def sendAgreement(agreement: SwapInAgreement): Behavior[SwapCommand] = { + // TODO: SHOULD fail any htlc that would change the channel into a state, where the swap invoice can not be payed until the swap invoice was payed. + sendShortId(register, shortChannelId)(agreement) + + receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { + case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) if agreement.protocolVersion == request.protocolVersion && agreement.swapId == swapId => + awaitOpeningTxConfirmed(agreement, openingTxBroadcasted) + case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) + case CancelReceived(_) => Behaviors.same + case StateTimeout => swapCanceled(InternalError(swapId, "timeout during sendAgreement")) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap agreement to peer.")) + case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during sendAgreement: $m") + case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) + sendCoopClose(s"Cancel requested by user after sending agreement.") + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "sendAgreement", ByteVector32.Zeroes, request, Some(agreement)) + Behaviors.same + } + } + + def awaitOpeningTxConfirmed(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) + watchForTxConfirmation(watcher)(openingConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), 3) // watch for opening tx to be confirmed + + receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { + case OpeningTxConfirmed(opening) => validateOpeningTx(agreement, openingTxBroadcasted, opening.tx) + case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during awaitOpeningTxConfirmed: $m") + case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) + case CancelReceived(_) => Behaviors.same + case InvoiceExpired => sendCoopClose("Timeout waiting for opening tx to confirm.") + case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) + sendCoopClose(s"Cancel requested by user while waiting for opening tx to confirm.") + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitOpeningTxConfirmed", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def validateOpeningTx(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction): Behavior[SwapCommand] = { + Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { + case Success(i) if i.amount_opt.isDefined && i.amount_opt.get > request.amount.sat.toMilliSatoshi => + context.self ! InvalidInvoice(s"Invoice amount ${i.amount_opt} > requested amount ${request.amount}") + case Success(i) if i.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => + context.self ! InvalidInvoice(s"Channel hop other than $shortChannelId found in invoice hints ${i.routingInfo}") + case Success(i) if i.isExpired() => + context.self ! InvalidInvoice(s"Invoice is expired.") + case Success(i) => context.self ! ValidInvoice(i) + case Failure(e) => context.self ! InvalidInvoice(s"Could not parse payreq: $e") + } + + receiveSwapMessage[ValidateTxMessages](context, "validateOpeningTx") { + case ValidInvoice(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash) => + payClaimInvoice(agreement, openingTxBroadcasted, invoice, openingTx) + case ValidInvoice(_) => sendCoopClose(s"Invalid opening tx: $openingTx", Some(openingTxBroadcasted)) + case InvalidInvoice(reason) => sendCoopClose(reason, Some(openingTxBroadcasted)) + case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during validateOpeningTx: $m", Some(openingTxBroadcasted)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) + sendCoopClose(s"Cancel requested by user while validating opening tx.", Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "validateOpeningTx", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def payClaimInvoice(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, openingTx: Transaction): Behavior[SwapCommand] = { + watchForPayment(watch = true) // subscribe to payment event notifications + payInvoice(nodeParams)(paymentInitiator, swapId, invoice) + + receiveSwapMessage[PayClaimInvoiceMessages](context, "payClaimInvoice") { + case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != invoice.paymentHash => Behaviors.same + case PaymentEventReceived(p: PaymentSent) => claimSwap(agreement, openingTxBroadcasted, invoice, p.paymentPreimage, openingTx) + case PaymentEventReceived(p: PaymentFailed) => sendCoopClose(s"Lightning payment failed: $p", Some(openingTxBroadcasted)) + case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(s"Lightning payment failed (invalid PaymentEvent received: $p).", Some(openingTxBroadcasted)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) + sendCoopClose(s"Cancel requested by user while paying claim invoice.", Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "payClaimInvoice", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def claimSwap(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, openingTx: Transaction): Behavior[SwapCommand] = { + val inputInfo = makeSwapOpeningInputInfo(openingTx.hash, openingTxBroadcasted.scriptOut.toInt, (request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash) + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + def claimByInvoiceConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) + + watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) + watchForPayment(watch = false) // unsubscribe from payment event notifications + commitClaim(wallet)(swapId, SwapClaimByCoopTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") + + receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { + case ClaimTxCommitted => Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByInvoiceConfirmed(swapId, confirmedTriggered)) + case SwapMessageReceived(m) => context.log.warn(s"received swap unhandled message while in state claimSwap: $m") + Behaviors.same + case ClaimTxFailed(error) => context.log.error(s"swap $swapId claim by invoice tx failed, error: $error") + Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? + case ClaimTxInvalid(e) => context.log.error(s"swap $swapId claim by invoice tx is invalid: $e, tx: $claimByInvoiceTx") + Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? + case StateTimeout => Behaviors.same // TODO: handle when claim tx not confirmed, retry or RBF the tx? can SwapInSender pin this tx with a low fee? + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after claim tx committed.") + Behaviors.same // ignore + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwap", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def sendCoopClose(reason: String, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None): Behavior[SwapCommand] = { + context.log.error(s"swap $swapId sent coop close, reason: $reason") + sendShortId(register, shortChannelId)(CoopClose(swapId, reason, takerPrivkey.toHex)) + def openingTxSpentAdapter: ActorRef[WatchOutputSpentTriggered] = context.messageAdapter[WatchOutputSpentTriggered](OpeningTxOutputSpent) + openingTxBroadcasted_opt match { + case Some(m) => watchForOutputSpent(watcher)(openingTxSpentAdapter, ByteVector32(ByteVector.fromValidHex(m.txId)), m.scriptOut.toInt) + receiveSwapMessage[SendCoopCloseMessages](context, "sendCoopClose") { + case OpeningTxOutputSpent(_) => swapCompleted(ClaimByCoopOffered(swapId, reason)) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap coop close to peer.")) + // TODO: set long enough timeout delay to wait for counterparty to sweep opening tx + case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) + swapCompleted(ClaimByCoopOffered(swapId, reason + "+ user canceled while waiting for opening tx to be swept by counter party.")) + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "sendCoopClose", ByteVector32.Zeroes, request, None, None, openingTxBroadcasted_opt) + Behaviors.same + } + case None => swapCompleted(ClaimByCoopOffered(swapId, reason)) + } + } + + def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(event) + context.log.info(s"completed swap $swapId: $event.") + Behaviors.stopped + } + + def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(Canceled(swapId)) + failure match { + case e: Error => context.log.error(s"canceled swap: $e") + case s: Fail => context.log.info(s"canceled swap: $s") + case _ => context.log.error(s"canceled swap $swapId, reason: unknown.") + } + Behaviors.stopped + } + +} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala new file mode 100644 index 0000000000..e1869e142f --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala @@ -0,0 +1,204 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapData.SwapInReceiverData +import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import java.util.UUID +import scala.concurrent.duration._ + +// with BitcoindService +case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 1 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Bob.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val keyManager: SwapKeyManager = TestConstants.Bob.nodeParams.swapKeyManager + val makerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val takerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val makerPubkey: PublicKey = makerPrivkey.publicKey + val takerPubkey: PublicKey = takerPrivkey.publicKey + val feeRatePerKw: FeeratePerKw = TestConstants.Bob.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Bob.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium: Long = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong + val paymentPreimage: ByteVector32 = ByteVector32.One + val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapInReceiver invoice"), CltvExpiryDelta(18)) + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + + val wallet = new DummyOnChainWallet() + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + + // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInReceiver(request, TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + + withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path from restored swap") { f => + import f._ + + // restore the SwapInReceiver actor state from a confirmed on-chain opening tx + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) + val swapData = SwapInReceiverData(request, agreement, invoice, openingTxBroadcasted) + swapInReceiver ! RestoreSwapInReceiver(swapData) + monitor.expectMessageType[RestoreSwapInReceiver] + + // SwapInReceiver reports status of awaiting payment + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitOpeningTxConfirmed") + + // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut((request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash)), 0) + swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + monitor.expectMessageType[OpeningTxConfirmed] + + // SwapInReceiver validates invoice and opening transaction before paying the invoice + monitor.expectMessageType[ValidInvoice] + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + + // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver ignores payments that do not correspond to the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), ByteVector32.Zeroes, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + monitor.expectMessageType[PaymentEventReceived].paymentEvent + monitor.expectNoMessage() + + // SwapInReceiver commits a claim-by-invoice transaction after successfully paying the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice.paymentHash, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val paymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(paymentEvent.isInstanceOf[PaymentSent] && paymentEvent.paymentHash === invoice.paymentHash) + monitor.expectMessage(ClaimTxCommitted) + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + monitor.expectMessageType[ClaimTxConfirmed] + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + } + + test("happy path for new swap") { f => + import f._ + + // start new SwapInSender + swapInReceiver ! StartSwapInReceiver + monitor.expectMessage(StartSwapInReceiver) + + // Taker:SwapInAgreement -> Maker + val agreement = register.expectMessageType[ForwardShortId[SwapInAgreement]].message + + // Maker:OpeningTxBroadcasted -> Taker + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + swapInReceiver ! SwapMessageReceived(openingTxBroadcasted) + monitor.expectMessageType[SwapMessageReceived] + + // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut((request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash)), 0) + swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + monitor.expectMessageType[OpeningTxConfirmed] + + // SwapInReceiver validates invoice and opening transaction before paying the invoice + monitor.expectMessageType[ValidInvoice] + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + + // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver ignores payments that do not correspond to the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), ByteVector32.Zeroes, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + monitor.expectMessageType[PaymentEventReceived].paymentEvent + monitor.expectNoMessage() + + // SwapInReceiver commits a claim-by-invoice transaction after successfully paying the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice.paymentHash, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val paymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(paymentEvent.isInstanceOf[PaymentSent] && paymentEvent.paymentHash === invoice.paymentHash) + monitor.expectMessage(ClaimTxCommitted) + + // SwapInReceiver reports status of awaiting claim by invoice tx to confirm + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapInStatus].behavior == "claimSwap") + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + monitor.expectMessageType[ClaimTxConfirmed] + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + } +} From d7e6c5b9865f2083005b3c63af1eca217936a9c1 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 21 Jun 2022 15:44:06 +0200 Subject: [PATCH 08/23] Add SwapRegister to forward peerswap messages to swap actors --- .../main/scala/fr/acinq/eclair/Setup.scala | 5 +- .../fr/acinq/eclair/swap/SwapRegister.scala | 135 ++++++++++++++ .../acinq/eclair/swap/SwapRegisterSpec.scala | 167 ++++++++++++++++++ 3 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index caaf434ac3..edfe305d3e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -43,7 +43,7 @@ import fr.acinq.eclair.payment.receive.PaymentHandler import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator} import fr.acinq.eclair.router._ -import fr.acinq.eclair.swap.LocalSwapKeyManager +import fr.acinq.eclair.swap.{LocalSwapKeyManager, SwapRegister} import fr.acinq.eclair.tor.{Controller, TorProtocolHandler} import fr.acinq.eclair.wire.protocol.NodeAddress import grizzled.slf4j.Logging @@ -305,12 +305,13 @@ class Setup(val datadir: File, txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, watcher, bitcoinClient) channelFactory = Peer.SimpleChannelFactory(nodeParams, watcher, relayer, bitcoinClient, txPublisherFactory) + paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, PaymentInitiator.SimplePaymentFactory(nodeParams, router, register)), "payment-initiator", SupervisorStrategy.Restart)) + swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcher, register, bitcoinClient)).onFailure(typed.SupervisorStrategy.resume), "swap-register") peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory) switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, peerFactory), "switchboard", SupervisorStrategy.Resume)) clientSpawner = system.actorOf(SimpleSupervisor.props(ClientSpawner.props(nodeParams.keyPair, nodeParams.socksProxy_opt, nodeParams.peerConnectionConf, switchboard, router), "client-spawner", SupervisorStrategy.Restart)) server = system.actorOf(SimpleSupervisor.props(Server.props(nodeParams.keyPair, nodeParams.peerConnectionConf, switchboard, router, serverBindingAddress, Some(tcpBound)), "server", SupervisorStrategy.Restart)) - paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, PaymentInitiator.SimplePaymentFactory(nodeParams, router, register)), "payment-initiator", SupervisorStrategy.Restart)) _ = for (i <- 0 until config.getInt("autoprobe-count")) yield system.actorOf(SimpleSupervisor.props(Autoprobe.props(nodeParams, router, paymentInitiator), s"payment-autoprobe-$i", SupervisorStrategy.Restart)) balanceActor = system.spawn(BalanceActor(nodeParams.db, bitcoinClient, channelsListener, nodeParams.balanceCheckInterval), name = "balance-actor") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala new file mode 100644 index 0000000000..2be5c4cc68 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala @@ -0,0 +1,135 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor +import akka.actor.typed +import akka.actor.typed.ActorRef.ActorRefOps +import akka.actor.typed.scaladsl.AskPattern.Askable +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapData.SwapInSenderData +import fr.acinq.eclair.swap.SwapRegister.Command +import fr.acinq.eclair.swap.SwapResponses.{Response, Status, SwapOpened} +import fr.acinq.eclair.wire.protocol.{HasSwapId, SwapInRequest} +import fr.acinq.eclair.{NodeParams, randomBytes32} +import scodec.bits.ByteVector + +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, Future} +import scala.reflect.ClassTag + +object SwapRegister { + // @formatter:off + sealed trait Command + sealed trait ReplyToMessages extends Command { + def replyTo: ActorRef[Response] + } + + sealed trait RegisteringMessages extends Command + case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, channelId: ByteVector32) extends RegisteringMessages with ReplyToMessages + case class MessageReceived(message: HasSwapId) extends RegisteringMessages + case class SwapTerminated(swapInSenderId: SwapInSenderId) extends RegisteringMessages + case class ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) extends RegisteringMessages + case class CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) extends RegisteringMessages with ReplyToMessages + + sealed trait SwapId { + def id: String + } + case class SwapInSenderId(id: String) extends SwapId { + def toByteVector32: ByteVector32 = ByteVector32(ByteVector.fromValidHex(id)) + } + // @formatter:on + + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapInSenderData] = Set()): Behavior[Command] = Behaviors.setup { context => + new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, wallet, data).initializing + } +} + +private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapInSenderData] = Set()) { + import SwapRegister._ + + private def myReceive[B <: Command : ClassTag](stateName: String)(f: B => Behavior[Command]): Behavior[Command] = + Behaviors.receiveMessage[Command] { + case m: B => f(m) + case m => + // m.replyTo ! Unhandled(stateName, m.getClass.getSimpleName) + context.log.error(s"received unhandled message while in state $stateName of ${m.getClass.getSimpleName}") + Behaviors.same + } + + private def initializing: Behavior[Command] = { + // TODO: restore SwapInReceiver from 'data' + // TODO: restore 'data' from database + val swaps = data.map { state => + val swap: typed.ActorRef[SwapCommands.SwapCommand] = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) + .onFailure(typed.SupervisorStrategy.restart), "SwapInSender-"+state.channelId.toHex) + context.watchWith(swap, SwapTerminated(SwapInSenderId(state.request.swapId))) + swap ! RestoreSwapInSender(state) + SwapInSenderId(state.request.swapId) -> swap.unsafeUpcast + }.toMap + registering(swaps) + } + + private def registering(swaps: Map[SwapInSenderId, ActorRef[Any]]): Behavior[Command] = { + // TODO: fail requests for swaps on a channel if one already exists for the channel; keep a list of channels with active swaps + myReceive[RegisteringMessages]("registering") { + case SwapInRequested(replyTo, amount, channelId) => + val swapId = randomBytes32().toHex + val swap = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) + .onFailure(SupervisorStrategy.restart), "SwapInSender-"+channelId.toHex) + context.watchWith(swap, SwapTerminated(SwapInSenderId(swapId))) + swap ! StartSwapInSender(amount, swapId, channelId) + replyTo ! SwapOpened(swapId) + registering(swaps + (SwapInSenderId(swapId) -> swap.unsafeUpcast)) + + case MessageReceived(request: SwapInRequest) => + val swap = context.spawn(Behaviors.supervise(SwapInReceiver(request, nodeParams, paymentInitiator, watcher, register, wallet)) + .onFailure(SupervisorStrategy.restart), "SwapInReceiver-"+request.scid) + context.watchWith(swap, SwapTerminated(SwapInSenderId(request.swapId))) + swap ! StartSwapInReceiver + registering(swaps + (SwapInSenderId(request.swapId) -> swap.unsafeUpcast)) + + case MessageReceived(msg) => swaps.get(SwapInSenderId(msg.swapId)) match { + case Some(swap) => swap ! SwapMessageReceived(msg) + Behaviors.same + case None => context.log.error(s"received unhandled message for swap ${msg.swapId}: $msg") + Behaviors.same + } + + case SwapTerminated(swapInSenderId) => registering(swaps - SwapInSenderId(swapInSenderId.id)) + + case ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) => + // TODO: is this the best way to do this?! + val statuses: Iterable[Future[Status]] = swaps.values.map(swap => swap.ask(ref => GetStatus(ref))(1000 milliseconds, context.system.scheduler)) + replyTo ! statuses.map(v => Await.result(v, 1000 milliseconds)) + Behaviors.same + + case CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) => + swaps.get(SwapInSenderId(swapId)) match { + case Some(swap) => swap ! CancelRequested(replyTo) + Behaviors.same + case None => context.log.error(s"could not cancel swap $swapId: does not exist") + Behaviors.same + } + } + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala new file mode 100644 index 0000000000..42754c5d96 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala @@ -0,0 +1,167 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchTxConfirmed, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.Register.Forward +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, DATA_NORMAL, RES_GET_CHANNEL_DATA} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.swap.SwapData.SwapInSenderData +import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} +import fr.acinq.eclair.swap.SwapRegister.{MessageReceived, SwapInRequested, SwapTerminated} +import fr.acinq.eclair.swap.SwapResponses.{Response, SwapOpened} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import org.mockito.scalatest.IdiomaticMockito +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, Outcome, ParallelTestExecution} +import scodec.bits.HexStringSyntax + +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} + +class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with BeforeAndAfterAll with Matchers with FixtureAnyFunSuiteLike with IdiomaticMockito with ParallelTestExecution { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 1 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val bobPayoutPubkey: PublicKey = PublicKey(hex"0270685ca81a8e4d4d01beec5781f4cc924684072ae52c507f8ebe9daf0caaab7b") + val premium = 10 + val scriptOut = 0 + val blindingKey = "" + val txId: String = ByteVector32.One.toHex + + val alicePrivkey: PrivateKey = PrivateKey(randomBytes32()) + val alicePubkey: PublicKey = alicePrivkey.publicKey + val bobPubkey: PublicKey = PrivateKey(randomBytes32()).publicKey + val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, alicePrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) + val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + + override def withFixture(test: OneArgTest): Outcome = { + val userCli = testKit.createTestProbe[Response]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val register = testKit.createTestProbe[Any]() + val monitor = testKit.createTestProbe[SwapRegister.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val wallet = new DummyOnChainWallet() { + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(6930 sat, 0 sat)) + } + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + + // subscribe to notification events from SwapInSender when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + withFixture(test.toNoArgTest(FixtureParam(userCli, swapEvents, register, monitor, paymentHandler, wallet, watcher))) + } + + case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], monitor: TestProbe[SwapRegister.Command], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[ZmqWatcher.Command]) + + test("restore the swap register from the database") { f => + import f._ + + val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey.toString()) + val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId, bobPubkey.toString(), premium) + val openingTxBroadcasted: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txId, scriptOut, blindingKey) + val savedData: Set[SwapInSenderData] = Set(SwapInSenderData(channelId, swapInRequest, swapInAgreement, invoice, openingTxBroadcasted)) + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, savedData)), "SwapRegister") + + // SwapInSender confirms opening tx on-chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // Bob: payment(paymentHash) -> Alice + val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get.paymentHash + val paymentReceived = PaymentReceived(paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapRegister received notice that SwapInSender completed + assert(swapEvents.expectMessageType[ClaimByInvoicePaid].swapId === swapId) + + // SwapRegister receives notification that the swap actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapInSenderId.id === swapId) + + testKit.stop(swapRegister) + } + + test("register a new swap in the swap register ") { f => + import f._ + + // initialize SwapRegister + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "SwapRegister") + swapEvents.expectNoMessage() + userCli.expectNoMessage() + + // User:SwapInRequested -> SwapInRegister + swapRegister ! SwapInRequested(userCli.ref, amount, channelId) + val swapId = userCli.expectMessageType[SwapOpened].swapId + monitor.expectMessageType[SwapInRequested] + + // Alice will first request channel data to get shortChannelId + val getChannelData = register.expectMessageType[Forward[CMD_GET_CHANNEL_DATA]] + getChannelData.replyTo.toClassic ! RES_GET_CHANNEL_DATA(channelData) + + // Alice:SwapInRequest -> Bob + val swapInRequest = register.expectMessageType[Forward[SwapInRequest]] + assert(swapId === swapInRequest.message.swapId) + + // Bob: SwapInAgreement -> Alice + swapRegister ! MessageReceived(SwapInAgreement(swapInRequest.message.protocolVersion, swapInRequest.message.swapId, bobPayoutPubkey.toString(), premium)) + monitor.expectMessageType[MessageReceived] + + // SwapInSender confirms opening tx on-chain + val transactionPublishedEvent = swapEvents.expectMessageType[TransactionPublished] + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, transactionPublishedEvent.tx) + + // Alice:OpeningTxBroadcasted -> Bob + val openingTxBroadcasted = register.expectMessageType[Forward[OpeningTxBroadcasted]] + + // Bob: payment(paymentHash) -> Alice + val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get.paymentHash + val paymentReceived = PaymentReceived(paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapRegister received notice that SwapInSender completed + assert(swapEvents.expectMessageType[ClaimByInvoicePaid].swapId === swapId) + + // SwapRegister receives notification that the swap actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapInSenderId.id === swapId) + + testKit.stop(swapRegister) + } + +} From 522fd3ad44065782db8c05cc891f32f480937b84 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 21 Jun 2022 15:48:18 +0200 Subject: [PATCH 09/23] Update Peer actor to forward peerswap messages from peers to the SwapRegister Add support for testing swaps --- .../main/scala/fr/acinq/eclair/Setup.scala | 2 +- .../main/scala/fr/acinq/eclair/io/Peer.scala | 17 +++- .../fr/acinq/eclair/io/Switchboard.scala | 5 +- .../basic/fixtures/MinimalNodeFixture.scala | 13 ++- .../scala/fr/acinq/eclair/io/PeerSpec.scala | 80 ++++++++++++------- 5 files changed, 77 insertions(+), 40 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index edfe305d3e..3e5d5bdc7e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -307,7 +307,7 @@ class Setup(val datadir: File, channelFactory = Peer.SimpleChannelFactory(nodeParams, watcher, relayer, bitcoinClient, txPublisherFactory) paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, PaymentInitiator.SimplePaymentFactory(nodeParams, router, register)), "payment-initiator", SupervisorStrategy.Restart)) swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcher, register, bitcoinClient)).onFailure(typed.SupervisorStrategy.resume), "swap-register") - peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory) + peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory, swapRegister) switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, peerFactory), "switchboard", SupervisorStrategy.Resume)) clientSpawner = system.actorOf(SimpleSupervisor.props(ClientSpawner.props(nodeParams.keyPair, nodeParams.socksProxy_opt, nodeParams.peerConnectionConf, switchboard, router), "client-spawner", SupervisorStrategy.Restart)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index d5538541d4..a4e69bdfdc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -38,8 +38,10 @@ import fr.acinq.eclair.io.PeerConnection.KillReason import fr.acinq.eclair.io.Switchboard.RelayMessage import fr.acinq.eclair.message.OnionMessages import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes +import fr.acinq.eclair.swap.SwapRegister +import fr.acinq.eclair.swap.SwapRegister.MessageReceived import fr.acinq.eclair.wire.protocol -import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, NodeAddress, OnionMessage, RoutingMessage, UnknownMessage, Warning} +import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasSwapId, HasTemporaryChannelId, LightningMessage, NodeAddress, OnionMessage, RoutingMessage, UnknownMessage, Warning} import scodec.bits.ByteVector import scala.concurrent.{ExecutionContext, Future} @@ -55,7 +57,7 @@ import scala.util.{Failure, Success} * * Created by PM on 26/08/2016. */ -class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory, switchboard: ActorRef) extends FSMDiagnosticActorLogging[Peer.State, Peer.Data] { +class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory, switchboard: ActorRef, swapRegister: typed.ActorRef[SwapRegister.Command]) extends FSMDiagnosticActorLogging[Peer.State, Peer.Data] { import Peer._ @@ -298,6 +300,10 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA replyTo_opt.foreach(_ ! MessageRelay.Sent(messageId)) stay() + case Event(message: HasSwapId, d: ConnectedData) => + swapRegister ! MessageReceived(message) + stay() + case Event(unknownMsg: UnknownMessage, d: ConnectedData) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => context.system.eventStream.publish(UnknownMessageReceived(self, remoteNodeId, unknownMsg, d.connectionInfo)) stay() @@ -403,6 +409,11 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA self ! Peer.OutgoingMessage(msg, peerConnection) } + def replyUnknownSwap(peerConnection: ActorRef, unknownSwapId: String): Unit = { + val msg = Warning(s"unknown swap id $unknownSwapId") + self ! Peer.OutgoingMessage(msg, peerConnection) + } + def handleOpenChannel(open: Either[protocol.OpenChannel, protocol.OpenDualFundedChannel], temporaryChannelId: ByteVector32, fundingAmount: Satoshi, channelFlags: ChannelFlags, channelType_opt: Option[ChannelType], d: ConnectedData): Unit = { validateRemoteChannelType(temporaryChannelId, channelFlags, channelType_opt, d.localFeatures, d.remoteFeatures) match { case Right(channelType) => @@ -483,7 +494,7 @@ object Peer { context.actorOf(Channel.props(nodeParams, wallet, remoteNodeId, watcher, relayer, txPublisherFactory, origin_opt)) } - def props(nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: ChannelFactory, switchboard: ActorRef): Props = Props(new Peer(nodeParams, remoteNodeId, wallet, channelFactory, switchboard)) + def props(nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: ChannelFactory, switchboard: ActorRef, swapRegister: typed.ActorRef[SwapRegister.Command]): Props = Props(new Peer(nodeParams, remoteNodeId, wallet, channelFactory, switchboard, swapRegister)) // @formatter:off diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala index bfcd514462..d7be78ed58 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala @@ -28,6 +28,7 @@ import fr.acinq.eclair.io.MessageRelay.RelayPolicy import fr.acinq.eclair.io.Peer.PeerInfoResponse import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes import fr.acinq.eclair.router.Router.RouterConf +import fr.acinq.eclair.swap.SwapRegister import fr.acinq.eclair.wire.protocol.OnionMessage import fr.acinq.eclair.{SubscriptionsComplete, NodeParams} @@ -152,9 +153,9 @@ object Switchboard { def spawn(context: ActorContext, remoteNodeId: PublicKey): ActorRef } - case class SimplePeerFactory(nodeParams: NodeParams, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory) extends PeerFactory { + case class SimplePeerFactory(nodeParams: NodeParams, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory, swapRegister: typed.ActorRef[SwapRegister.Command]) extends PeerFactory { override def spawn(context: ActorContext, remoteNodeId: PublicKey): ActorRef = - context.actorOf(Peer.props(nodeParams, remoteNodeId, wallet, channelFactory, context.self), name = peerActorName(remoteNodeId)) + context.actorOf(Peer.props(nodeParams, remoteNodeId, wallet, channelFactory, context.self, swapRegister), name = peerActorName(remoteNodeId)) } def props(nodeParams: NodeParams, peerFactory: PeerFactory) = Props(new Switchboard(nodeParams, peerFactory)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 68a78494c6..73ff3635f5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -1,6 +1,8 @@ package fr.acinq.eclair.integration.basic.fixtures -import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps +import akka.actor.typed.SupervisorStrategy +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps, TypedActorRefOps} import akka.actor.{ActorRef, ActorSystem} import akka.testkit.{TestActor, TestProbe} import com.softwaremill.quicklens.ModifyPimp @@ -24,7 +26,7 @@ import fr.acinq.eclair.payment.receive.{MultiPartHandler, PaymentHandler} import fr.acinq.eclair.payment.relay.{ChannelRelayer, Relayer} import fr.acinq.eclair.payment.send.PaymentInitiator import fr.acinq.eclair.router.Router -import fr.acinq.eclair.swap.LocalSwapKeyManager +import fr.acinq.eclair.swap.{LocalSwapKeyManager, SwapRegister} import fr.acinq.eclair.wire.protocol.IPAddress import fr.acinq.eclair.{BlockHeight, MilliSatoshi, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases, TestFeeEstimator} import org.scalatest.concurrent.PatienceConfiguration @@ -50,6 +52,7 @@ case class MinimalNodeFixture private(nodeParams: NodeParams, switchboard: ActorRef, paymentInitiator: ActorRef, paymentHandler: ActorRef, + swapRegister: ActorRef, watcher: TestProbe, wallet: DummyOnChainWallet, bitcoinClient: TestBitcoinCoreClient) @@ -87,10 +90,11 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat val relayer = system.actorOf(Relayer.props(nodeParams, router, register, paymentHandler), "relayer") val txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, watcherTyped, bitcoinClient) val channelFactory = Peer.SimpleChannelFactory(nodeParams, watcherTyped, relayer, wallet, txPublisherFactory) - val peerFactory = Switchboard.SimplePeerFactory(nodeParams, wallet, channelFactory) - val switchboard = system.actorOf(Switchboard.props(nodeParams, peerFactory), "switchboard") val paymentFactory = PaymentInitiator.SimplePaymentFactory(nodeParams, router, register) val paymentInitiator = system.actorOf(PaymentInitiator.props(nodeParams, paymentFactory), "payment-initiator") + val swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcherTyped, register, wallet)).onFailure(SupervisorStrategy.stop), "swap-register") + val peerFactory = Switchboard.SimplePeerFactory(nodeParams, wallet, channelFactory, swapRegister) + val switchboard = system.actorOf(Switchboard.props(nodeParams, peerFactory), "switchboard") readyListener.expectMsgAllOf( SubscriptionsComplete(classOf[Router]), SubscriptionsComplete(classOf[Register]), @@ -105,6 +109,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat switchboard = switchboard, paymentInitiator = paymentInitiator, paymentHandler = paymentHandler, + swapRegister = swapRegister.toClassic, watcher = watcher, wallet = wallet, bitcoinClient = bitcoinClient diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala index 8fd1ce1c2e..f56f7cb9a8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala @@ -34,12 +34,14 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.ChannelStateTestsTags import fr.acinq.eclair.io.Peer._ import fr.acinq.eclair.message.OnionMessages.{Recipient, buildMessage} +import fr.acinq.eclair.swap.SwapRegister +import fr.acinq.eclair.swap.SwapRegister.MessageReceived import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol._ import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, ParallelTestExecution, Tag} -import scodec.bits.ByteVector +import scodec.bits.{ByteVector, HexStringSyntax} import java.net.InetSocketAddress import java.nio.channels.ServerSocketChannel @@ -51,7 +53,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val fakeIPAddress: NodeAddress = NodeAddress.fromParts("1.2.3.4", 42000).get - case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, switchboard: TestProbe) + case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, switchboard: TestProbe, swapRegister: TestProbe) case class FakeChannelFactory(channel: TestProbe) extends ChannelFactory { override def spawn(context: ActorContext, remoteNodeId: PublicKey, origin_opt: Option[ActorRef]): ActorRef = { @@ -66,6 +68,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val peerConnection = TestProbe() val channel = TestProbe() val switchboard = TestProbe() + val swapRegister = TestProbe() import com.softwaremill.quicklens._ val aliceParams = TestConstants.Alice.nodeParams @@ -83,11 +86,11 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle aliceParams.db.network.addNode(bobAnnouncement) } - val peer: TestFSMRef[Peer.State, Peer.Data, Peer] = TestFSMRef(new Peer(aliceParams, remoteNodeId, wallet, FakeChannelFactory(channel), switchboard.ref)) - withFixture(test.toNoArgTest(FixtureParam(aliceParams, remoteNodeId, peer, peerConnection, channel, switchboard))) + val peer: TestFSMRef[Peer.State, Peer.Data, Peer] = TestFSMRef(new Peer(aliceParams, remoteNodeId, wallet, FakeChannelFactory(channel), switchboard.ref, swapRegister.ref.toTyped[SwapRegister.Command])) + withFixture(test.toNoArgTest(FixtureParam(aliceParams, remoteNodeId, peer, peerConnection, channel, switchboard, swapRegister))) } - def connect(remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, switchboard: TestProbe, channels: Set[PersistentChannelData] = Set.empty, remoteInit: protocol.Init = protocol.Init(Bob.nodeParams.features.initFeatures())): Unit = { + def connect(remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, switchboard: TestProbe, swapRegister: TestProbe, channels: Set[PersistentChannelData] = Set.empty, remoteInit: protocol.Init = protocol.Init(Bob.nodeParams.features.initFeatures())): Unit = { // let's simulate a connection switchboard.send(peer, Peer.Init(channels)) val localInit = protocol.Init(peer.underlyingActor.nodeParams.features.initFeatures()) @@ -103,7 +106,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("restore existing channels") { f => import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) probe.send(peer, Peer.GetPeerInfo(None)) probe.expectMsg(PeerInfo(peer, remoteNodeId, Peer.CONNECTED, Some(fakeIPAddress), 1)) } @@ -179,7 +182,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) probe.send(peer, Peer.Connect(remoteNodeId, None, probe.ref, isPersistent = true)) probe.expectMsgType[PeerConnection.ConnectionResult.AlreadyConnected] @@ -190,7 +193,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val listener = TestProbe() system.eventStream.subscribe(listener.ref, classOf[UnknownMessageReceived]) - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) peerConnection.send(peer, UnknownMessage(tag = TestConstants.pluginParams.messageTags.head, data = ByteVector.empty)) listener.expectMsgType[UnknownMessageReceived] @@ -202,7 +205,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) probe.send(peer, Peer.GetPeerInfo(Some(probe.ref.toTyped))) assert(probe.expectMsgType[Peer.PeerInfo].state == Peer.CONNECTED) @@ -233,7 +236,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val peerConnection2 = TestProbe() val peerConnection3 = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) channel.expectMsg(INPUT_RESTORED(ChannelCodecsSpec.normal)) val (localInit, remoteInit) = { val inputReconnected = channel.expectMsgType[INPUT_RECONNECTED] @@ -286,7 +289,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) assert(peer.stateData.channels.isEmpty) val open = createOpenChannelMessage() @@ -308,7 +311,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() val fundingAmountBig = Channel.MAX_FUNDING + 10000.sat system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, fundingAmountBig, None, None, None, None, None)) @@ -322,7 +325,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() val fundingAmountBig = Channel.MAX_FUNDING + 10000.sat system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard) // Bob doesn't support wumbo, Alice does + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) // Bob doesn't support wumbo, Alice does assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, fundingAmountBig, None, None, None, None, None)) @@ -336,7 +339,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() val fundingAmountBig = Btc(1).toSatoshi system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(Wumbo -> Optional))) // Bob supports wumbo + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(Wumbo -> Optional))) // Bob supports wumbo assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, fundingAmountBig, None, None, None, None, None)) @@ -347,7 +350,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("don't spawn a channel if we don't support their channel type") { f => import f._ - connect(remoteNodeId, peer, peerConnection, switchboard) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) assert(peer.stateData.channels.isEmpty) // They only support anchor outputs and we don't. @@ -380,7 +383,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val remoteInit = protocol.Init(Features(ChannelType -> Optional)) - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = remoteInit) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = remoteInit) assert(peer.stateData.channels.isEmpty) val open = createOpenChannelMessage() peerConnection.send(peer, open) @@ -390,7 +393,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("don't spawn a dual funded channel if not supported") { f => import f._ - connect(remoteNodeId, peer, peerConnection, switchboard) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) val open = createOpenDualFundedChannelMessage() peerConnection.send(peer, open) peerConnection.expectMsg(Error(open.temporaryChannelId, "dual funding is not supported")) @@ -401,7 +404,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() // Both peers support option_dual_fund, so it is automatically used. - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, 25000 sat, None, None, None, None, None)) assert(channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR].dualFunded) @@ -411,7 +414,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ // Both peers support option_dual_fund, so it is automatically used. - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) assert(peer.stateData.channels.isEmpty) val open = createOpenDualFundedChannelMessage() peerConnection.send(peer, open) @@ -424,7 +427,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ // We both support option_static_remotekey but they want to open a standard channel. - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional))) assert(peer.stateData.channels.isEmpty) val open = createOpenChannelMessage(TlvStream[OpenChannelTlv](ChannelTlv.ChannelTypeTlv(ChannelTypes.Standard))) peerConnection.send(peer, open) @@ -439,7 +442,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, 15000 sat, None, None, None, None, None)) @@ -458,7 +461,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional))) assert(peer.stateData.channels.isEmpty) // We ensure the current network feerate is higher than the default anchor output feerate. @@ -477,7 +480,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional))) assert(peer.stateData.channels.isEmpty) // We ensure the current network feerate is higher than the default anchor output feerate. @@ -496,7 +499,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) probe.send(peer, Peer.OpenChannel(remoteNodeId, 24000 sat, None, None, None, None, None)) val init = channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR] assert(init.channelType == ChannelTypes.StaticRemoteKey) @@ -523,8 +526,9 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle channel.ref } } - val peer = TestFSMRef(new Peer(TestConstants.Alice.nodeParams, remoteNodeId, new DummyOnChainWallet(), channelFactory, switchboard.ref)) - connect(remoteNodeId, peer, peerConnection, switchboard) + val swapRegister = TestProbe() + val peer = TestFSMRef(new Peer(TestConstants.Alice.nodeParams, remoteNodeId, new DummyOnChainWallet(), channelFactory, switchboard.ref, swapRegister.ref.toTyped[SwapRegister.Command])) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) probe.send(peer, Peer.OpenChannel(remoteNodeId, 15000 sat, None, Some(100 msat), None, None, None)) val init = channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR] assert(init.fundingAmount == 15000.sat) @@ -534,7 +538,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("handle final channelId assigned in state DISCONNECTED") { f => import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) peer ! ConnectionDown(peerConnection.ref) probe.send(peer, Peer.GetPeerInfo(Some(probe.ref.toTyped))) val peerInfo1 = probe.expectMsgType[Peer.PeerInfo] @@ -551,7 +555,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() system.eventStream.subscribe(probe.ref, classOf[LastChannelClosed]) - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) probe.send(channel.ref, PoisonPill) probe.expectMsg(LastChannelClosed(peer, remoteNodeId)) } @@ -560,7 +564,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() system.eventStream.subscribe(probe.ref, classOf[LastChannelClosed]) - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) peer ! ConnectionDown(peerConnection.ref) probe.send(channel.ref, PoisonPill) probe.expectMsg(LastChannelClosed(peer, remoteNodeId)) @@ -568,7 +572,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("reply to relay request") { f => import f._ - connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) val (_, msg) = buildMessage(randomKey(), randomKey(), Nil, Recipient(remoteNodeId, None), Nil) val messageId = randomBytes32() val probe = TestProbe() @@ -584,6 +588,22 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle peer ! RelayOnionMessage(messageId, msg, Some(probe.ref.toTyped)) probe.expectMsg(MessageRelay.Disconnected(messageId)) } + + test("forward messages with a swapId defined to the SwapRegister") { f => + import f._ + connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + + val protocolVersion = 1 + val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" + val premium = 10 + val responderPubkey = randomKey().publicKey + + val swapInAgreement = SwapInAgreement(protocolVersion, swapId.toHex, responderPubkey.toString, premium) + + peerConnection.send(peer, swapInAgreement) + val messageReceived = swapRegister.expectMsgType[MessageReceived] + assert(messageReceived.message === swapInAgreement) + } } object PeerSpec { From a3dae29574b5b82ee67070a42d0e2f0b364ff1de Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 7 Jul 2022 16:18:36 +0200 Subject: [PATCH 10/23] Update Channel actor to forward peerswap messages to Peer --- .../src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 0bb17af97e..06fb6710d3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1641,6 +1641,9 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val case Event(WatchFundingSpentTriggered(tx), d: PersistentChannelData) if tx.txid == d.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid => log.warning(s"processing local commit spent in catch-all handler") spendLocalCurrent(d) + + case Event(msg: HasSwapId, _) => send(msg) + stay() } onTransition { From df69b977cfa8d954b8eba34e721f020f9d101e70 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 7 Jul 2022 17:44:06 +0200 Subject: [PATCH 11/23] Add SwapInSend/Receive integration test --- .../blockchain/DummyOnChainWallet.scala | 7 +- .../eclair/swap/SwapIntegrationFixture.scala | 54 +++++ .../eclair/swap/SwapIntegrationSpec.scala | 222 ++++++++++++++++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationFixture.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index 0891c5ae23..664f9fb0c9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.{randomBytes32, randomKey} import scodec.bits._ +import scala.collection.concurrent.TrieMap import scala.concurrent.{ExecutionContext, Future, Promise} /** @@ -35,10 +36,12 @@ class DummyOnChainWallet extends OnChainWallet { import DummyOnChainWallet._ - val funded = collection.concurrent.TrieMap.empty[ByteVector32, Transaction] + var confirmedBalance: Satoshi = 1105 sat + var unconfirmedBalance: Satoshi = 561 sat + val funded: TrieMap[ByteVector32, Transaction] = collection.concurrent.TrieMap.empty[ByteVector32, Transaction] var rolledback = Set.empty[Transaction] - override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(1105 sat, 561 sat)) + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(confirmedBalance, unconfirmedBalance)) override def getReceiveAddress(label: String)(implicit ec: ExecutionContext): Future[String] = Future.successful(dummyReceiveAddress) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationFixture.scala new file mode 100644 index 0000000000..29967139bf --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationFixture.scala @@ -0,0 +1,54 @@ +package fr.acinq.eclair.swap + +import akka.actor.ActorSystem +import akka.testkit.{TestKit, TestProbe} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchExternalChannelSpent +import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} +import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture +import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture.{confirmChannel, confirmChannelDeep, connect, getChannelData, getRouterData, openChannel} +import fr.acinq.eclair.payment.PaymentEvent +import fr.acinq.eclair.swap.SwapEvents.SwapEvent +import fr.acinq.eclair.{BlockHeight, NodeParams} +import org.scalatest.concurrent.Eventually.eventually + +case class SwapProbes(cli: TestProbe, paymentEvents: TestProbe, swapEvents: TestProbe) + +case class SwapIntegrationFixture(system: ActorSystem, alice: MinimalNodeFixture, bob: MinimalNodeFixture, aliceSwap: SwapProbes, bobSwap: SwapProbes, channelId: ByteVector32) { + implicit val implicitSystem: ActorSystem = system + + def cleanup(): Unit = { + TestKit.shutdownActorSystem(alice.system) + TestKit.shutdownActorSystem(bob.system) + TestKit.shutdownActorSystem(system) + } +} + +object SwapIntegrationFixture { + def apply(aliceParams: NodeParams, bobParams: NodeParams): SwapIntegrationFixture = { + val system = ActorSystem("system-test") + val alice = MinimalNodeFixture(aliceParams) + val bob = MinimalNodeFixture(bobParams) + val aliceSwap = SwapProbes(TestProbe()(alice.system), TestProbe()(alice.system), TestProbe()(alice.system)) + val bobSwap = SwapProbes(TestProbe()(bob.system), TestProbe()(bob.system), TestProbe()(bob.system)) + alice.system.eventStream.subscribe(aliceSwap.paymentEvents.ref, classOf[PaymentEvent]) + alice.system.eventStream.subscribe(aliceSwap.swapEvents.ref, classOf[SwapEvent]) + bob.system.eventStream.subscribe(bobSwap.paymentEvents.ref, classOf[PaymentEvent]) + bob.system.eventStream.subscribe(bobSwap.swapEvents.ref, classOf[SwapEvent]) + + connect(alice, bob)(system) + val channelId = openChannel(alice, bob, 100_000 sat)(system).channelId + confirmChannel(alice, bob, channelId, BlockHeight(420_000), 21)(system) + confirmChannelDeep(alice, bob, channelId, BlockHeight(420_000), 21)(system) + assert(getChannelData(alice, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) + assert(getChannelData(bob, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) + + eventually { + getRouterData(alice)(system).privateChannels.size == 1 + } + alice.watcher.expectMsgType[WatchExternalChannelSpent] + bob.watcher.expectMsgType[WatchExternalChannelSpent] + + SwapIntegrationFixture(system, alice, bob, aliceSwap, bobSwap, channelId) + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala new file mode 100644 index 0000000000..65e88ec61c --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala @@ -0,0 +1,222 @@ +package fr.acinq.eclair.swap + +import akka.actor.typed.scaladsl.adapter._ +import akka.actor.{ActorSystem, Kill} +import akka.testkit.TestProbe +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.eclair.BlockHeight +import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} +import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture +import fr.acinq.eclair.integration.basic.fixtures.composite.TwoNodesFixture +import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentSent} +import fr.acinq.eclair.swap.SwapEvents._ +import fr.acinq.eclair.swap.SwapRegister.{ListPendingSwaps, SwapInRequested} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapOpened} +import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.swap.SwapTransactions.claimByInvoiceTxWeight +import fr.acinq.eclair.testutils.FixtureSpec +import org.scalatest.TestData +import org.scalatest.concurrent.{IntegrationPatience, PatienceConfiguration} +import scodec.bits.HexStringSyntax + +import scala.concurrent.duration.DurationInt + +/** + * This test checks the integration between SwapInSender and SwapInReceiver + */ + +class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { + + type FixtureParam = TwoNodesFixture + + val SwapIntegrationConfAlice = "swap_integration_conf_alice" + val SwapIntegrationConfBob = "swap_integration_conf_bob" + + import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture._ + + override def createFixture(testData: TestData): FixtureParam = { + // seeds have been chosen so that node ids start with 02aaaa for alice, 02bbbb for bob, etc. + val aliceParams = nodeParamsFor("alice", ByteVector32(hex"b4acd47335b25ab7b84b8c020997b12018592bb4631b868762154d77fa8b93a3")) + val bobParams = nodeParamsFor("bob", ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492")) + .copy(invoiceExpiry = 2 seconds) + TwoNodesFixture(aliceParams, bobParams) + } + + override def cleanupFixture(fixture: FixtureParam): Unit = { + fixture.cleanup() + } + + def swapProbes(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): (SwapProbes, SwapProbes) = { + val aliceSwap = SwapProbes(TestProbe()(alice.system), TestProbe()(alice.system), TestProbe()(alice.system)) + val bobSwap = SwapProbes(TestProbe()(bob.system), TestProbe()(bob.system), TestProbe()(bob.system)) + alice.system.eventStream.subscribe(aliceSwap.paymentEvents.ref, classOf[PaymentEvent]) + alice.system.eventStream.subscribe(aliceSwap.swapEvents.ref, classOf[SwapEvent]) + bob.system.eventStream.subscribe(bobSwap.paymentEvents.ref, classOf[PaymentEvent]) + bob.system.eventStream.subscribe(bobSwap.swapEvents.ref, classOf[SwapEvent]) + (aliceSwap, bobSwap) + } + + def connectNodes(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): ByteVector32 = { + connect(alice, bob)(system) + val channelId = openChannel(alice, bob, 100_000 sat)(system).channelId + confirmChannel(alice, bob, channelId, BlockHeight(420_000), 21)(system) + confirmChannelDeep(alice, bob, channelId, BlockHeight(420_000), 21)(system) + assert(getChannelData(alice, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) + assert(getChannelData(bob, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) + + eventually(PatienceConfiguration.Timeout(2 seconds), PatienceConfiguration.Interval(1 second)) { + getRouterData(alice)(system).privateChannels.size == 1 + } + alice.watcher.expectMsgType[WatchExternalChannelSpent] + bob.watcher.expectMsgType[WatchExternalChannelSpent] + + channelId + } + + test("swap in - claim by invoice") { f => + import f._ + + val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val channelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByInvoiceBlock = BlockHeight(4) + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx on-chain + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + assert(openingTx.txOut.head.amount == amount + premium) + + // bob has status of 1 pending swap + bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // swap in receiver (alice) confirms opening tx on-chain + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + + // swap in receiver (alice) sends a payment of `amount` to swap in sender (bob) + assert(aliceSwap.paymentEvents.expectMsgType[PaymentSent].recipientAmount === toMilliSatoshi(amount)) + assert(bobSwap.paymentEvents.expectMsgType[PaymentReceived].amount === toMilliSatoshi(amount)) + + // swap in receiver (alice) confirms claim-by-invoice tx on-chain + val claimTx = aliceSwap.swapEvents.expectMsgType[TransactionPublished].tx + assert(claimTx.txOut.head.amount == amount) // added on-chain premium consumed as tx fee + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByInvoiceBlock, 0, claimTx) + + // both parties publish that the swap was completed via claim-by-invoice + assert(aliceSwap.swapEvents.expectMsgType[ClaimByInvoiceConfirmed].swapId == swapId) + assert(bobSwap.swapEvents.expectMsgType[ClaimByInvoicePaid].swapId == swapId) + } + + test("swap in - claim by coop, receiver does not have sufficient channel balance") { f => + import f._ + + val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val channelId = connectNodes(alice, bob) + + // swap more satoshis than alice has available in the channel to send to bob + val amount = 100_000 sat + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByCoopBlock = BlockHeight(2) + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx on-chain + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + assert(openingTx.txOut.head.amount == amount + premium) + + // bob has status of 1 pending swap + bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // alice has status of 1 pending swap + alice.swapRegister ! ListPendingSwaps(aliceSwap.cli.ref) + val aliceStatus = aliceSwap.cli.expectMsgType[Iterable[Status]] + assert(aliceStatus.size == 1) + assert(aliceStatus.head.swapId == swapId) + + // swap in receiver (alice) confirms opening tx on-chain + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + + // swap in sender (bob) confirms claim-by-coop tx on-chain + val claimTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCoopBlock, 0, claimTx) + + // swap in receiver (alice) confirms opening tx spent by claim tx + alice.watcher.expectMsgType[WatchOutputSpent].replyTo ! WatchOutputSpentTriggered(claimTx) + + // swap in receiver (alice) completed swap with coop cancel message to sender (bob) + val claimByCoopEvent = aliceSwap.swapEvents.expectMsgType[ClaimByCoopOffered] + assert(claimByCoopEvent.swapId == swapId) + + // swap in sender (bob) confirms completed swap with claim-by-coop tx + assert(bobSwap.swapEvents.expectMsgType[ClaimByCoopConfirmed].swapId == swapId) + } + + test("swap in - claim by csv, receiver does not pay after opening tx confirmed") { f => + import f._ + + val (_, bobSwap) = swapProbes(alice, bob) + val channelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByCsvBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx on-chain + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + + // swap in receiver (alice) stops unexpectedly + alice.swapRegister ! Kill + + // opening tx confirmed + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + assert(openingTx.txOut.head.amount == amount + premium) + + // bob has status of 1 pending swap + bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // opening tx buried by csv delay + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCsvBlock, 0, openingTx) + + // swap in sender (bob) confirms claim-by-csv tx on-chain if Alice does not send payment + val claimTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCsvBlock, 0, claimTx) + + // swap in sender (bob) confirms claim-by-csv + assert(bobSwap.swapEvents.expectMsgType[ClaimByCsvConfirmed].swapId == swapId) + } + +} From dc6ad3caee4e6f0d61e7343b95b425036140efda Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 15 Jul 2022 20:18:14 +0200 Subject: [PATCH 12/23] add cli/api for peerswap swapin, status and cancel --- .../main/scala/fr/acinq/eclair/Eclair.scala | 17 +++++++ .../main/scala/fr/acinq/eclair/Setup.scala | 6 ++- .../fr/acinq/eclair/EclairImplSpec.scala | 4 +- .../scala/fr/acinq/eclair/api/Service.scala | 4 +- .../api/directives/ExtraDirectives.scala | 4 +- .../acinq/eclair/api/handlers/PeerSwap.scala | 47 +++++++++++++++++++ 6 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 90ed250800..7d2cae4a2c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -45,6 +45,8 @@ import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.PreimageReceived import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.router.Router import fr.acinq.eclair.router.Router._ +import fr.acinq.eclair.swap.SwapRegister +import fr.acinq.eclair.swap.SwapResponses.{Response, Status} import fr.acinq.eclair.wire.protocol.MessageOnionCodecs.blindedRouteCodec import fr.acinq.eclair.wire.protocol._ import grizzled.slf4j.Logging @@ -163,6 +165,12 @@ trait Eclair { def sendOnionMessage(intermediateNodes: Seq[PublicKey], destination: Either[PublicKey, Sphinx.RouteBlinding.BlindedRoute], replyPath: Option[Seq[PublicKey]], userCustomContent: ByteVector)(implicit timeout: Timeout): Future[SendOnionMessageResponse] def stop(): Future[Unit] + + def swapIn(channelId: ByteVector32, amount: Satoshi)(implicit timeout: Timeout): Future[Response] + + def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] + + def cancelSwap(swapId: String)(implicit timeout: Timeout): Future[Response] } class EclairImpl(appKit: Kit) extends Eclair with Logging { @@ -580,4 +588,13 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { sys.exit(0) Future.successful(()) } + + override def swapIn(channelId: ByteVector32, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = + appKit.swapRegister.ask(ref => SwapRegister.SwapInRequested(ref, amount, channelId))(timeout, appKit.system.scheduler.toTyped) + + override def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] = + appKit.swapRegister.ask(ref => SwapRegister.ListPendingSwaps(ref))(timeout, appKit.system.scheduler.toTyped) + + override def cancelSwap(swapId: String)(implicit timeout: Timeout): Future[Response] = + appKit.swapRegister.ask(ref => SwapRegister.CancelSwapRequested(ref, swapId))(timeout, appKit.system.scheduler.toTyped) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index 3e5d5bdc7e..557674f831 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -332,7 +332,8 @@ class Setup(val datadir: File, channelsListener = channelsListener, balanceActor = balanceActor, postman = postman, - wallet = bitcoinClient) + wallet = bitcoinClient, + swapRegister = swapRegister) zmqBlockTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) zmqTxTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) @@ -400,7 +401,8 @@ case class Kit(nodeParams: NodeParams, channelsListener: typed.ActorRef[ChannelsListener.Command], balanceActor: typed.ActorRef[BalanceActor.Command], postman: typed.ActorRef[Postman.Command], - wallet: OnChainWallet) + wallet: OnChainWallet, + swapRegister: typed.ActorRef[SwapRegister.Command]) object Kit { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index dd34358c16..57d48665a8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -70,6 +70,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val channelsListener = TestProbe() val balanceActor = TestProbe() val postman = TestProbe() + val swapRegister = TestProbe() val kit = Kit( TestConstants.Alice.nodeParams, system, @@ -84,7 +85,8 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I channelsListener.ref.toTyped, balanceActor.ref.toTyped, postman.ref.toTyped, - new DummyOnChainWallet() + new DummyOnChainWallet(), + swapRegister.ref.toTyped ) withFixture(test.toNoArgTest(FixtureParam(register, relayer, router, paymentInitiator, switchboard, paymentHandler, TestProbe(), kit))) } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala index ee56239035..9995f5621e 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.api.handlers._ import grizzled.slf4j.Logging -trait Service extends EclairDirectives with WebSocket with Node with Channel with Fees with PathFinding with Invoice with Payment with Message with OnChain with Logging { +trait Service extends EclairDirectives with WebSocket with Node with Channel with Fees with PathFinding with Invoice with Payment with Message with OnChain with PeerSwap with Logging { /** * Allows router access to the API password as configured in eclair.conf @@ -46,7 +46,7 @@ trait Service extends EclairDirectives with WebSocket with Node with Channel wit * This is where we handle errors to ensure all routes are correctly tried before rejecting. */ def finalRoutes(extraRouteProviders: Seq[RouteProvider] = Nil): Route = securedHandler { - val baseRoutes = nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket + val baseRoutes = nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ peerSwapRoutes ~ webSocket extraRouteProviders.map(_.route(this)).foldLeft(baseRoutes)(_ ~ _) } } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala index ff11b940e8..cfaacff093 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala @@ -21,8 +21,8 @@ import akka.http.scaladsl.marshalling.ToResponseMarshaller import akka.http.scaladsl.model.StatusCodes.NotFound import akka.http.scaladsl.model.{ContentTypes, HttpResponse} import akka.http.scaladsl.server.{Directive1, Directives, MalformedFormFieldRejection, Route} -import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} import fr.acinq.eclair.ApiTypes.ChannelIdentifier import fr.acinq.eclair.api.serde.FormParamExtractors._ import fr.acinq.eclair.api.serde.JsonSupport._ @@ -47,11 +47,13 @@ trait ExtraDirectives extends Directives { val fromFormParam: NameDefaultUnmarshallerReceptacle[TimestampSecond] = "from".as[TimestampSecond](timestampSecondUnmarshaller).?(TimestampSecond.now() - 1.day) val toFormParam: NameDefaultUnmarshallerReceptacle[TimestampSecond] = "to".as[TimestampSecond](timestampSecondUnmarshaller).?(TimestampSecond.now()) val amountMsatFormParam: NameReceptacle[MilliSatoshi] = "amountMsat".as[MilliSatoshi] + val amountSatFormParam: NameReceptacle[Satoshi] = "amountSat".as[Satoshi] val invoiceFormParam: NameReceptacle[Bolt11Invoice] = "invoice".as[Bolt11Invoice] val routeFormatFormParam: NameUnmarshallerReceptacle[RouteFormat] = "format".as[RouteFormat](routeFormatUnmarshaller) val ignoreNodeIdsFormParam: NameUnmarshallerReceptacle[List[PublicKey]] = "ignoreNodeIds".as[List[PublicKey]](pubkeyListUnmarshaller) val ignoreShortChannelIdsFormParam: NameUnmarshallerReceptacle[List[ShortChannelId]] = "ignoreShortChannelIds".as[List[ShortChannelId]](shortChannelIdsUnmarshaller) val maxFeeMsatFormParam: NameReceptacle[MilliSatoshi] = "maxFeeMsat".as[MilliSatoshi] + val swapIdFormParam: NameUnmarshallerReceptacle[ByteVector32] = "swapId".as[ByteVector32](sha256HashUnmarshaller) // custom directive to fail with HTTP 404 (and JSON response) if the element was not found def completeOrNotFound[T](fut: Future[Option[T]])(implicit marshaller: ToResponseMarshaller[T]): Route = onComplete(fut) { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala new file mode 100644 index 0000000000..96d40525b9 --- /dev/null +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.api.handlers + +import akka.http.scaladsl.server.Route +import fr.acinq.eclair.api.Service +import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.api.serde.FormParamExtractors._ + +trait PeerSwap { + this: Service with EclairDirectives => + + import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization} + + val swapIn: Route = postRequest("swapin") { implicit t => + formFields(channelIdFormParam, amountSatFormParam) { (channelId, amount) => + complete(eclairApi.swapIn(channelId, amount)) + } + } + + val listSwaps: Route = postRequest("listswaps") { implicit t => + complete(eclairApi.listSwaps()) + } + + val cancelSwap: Route = postRequest("cancelswap") { implicit t => + formFields(swapIdFormParam) { swapId => + complete(eclairApi.cancelSwap(swapId.toString())) + } + } + + val peerSwapRoutes: Route = swapIn ~ listSwaps ~ cancelSwap + +} From 1ef5a997e6b6f4344f98aa6beea8f70c5f8b6ecb Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 7 Sep 2022 11:08:51 +0200 Subject: [PATCH 13/23] Fix errors found during integration testing - use different watch message to wait for csv - fix handling of received CancelSwap messages - send OpeningTxBroadcasted message before opening confirmed - fixed claim by coop and csv transactions by including premium - remove user commands during createSwap - do not send CancelSwap message for failures during createSwap - reverse txid when making transaction outpoints - clarify comments to distinguish when tx is published vs confirmed --- .../fr/acinq/eclair/swap/SwapCommands.scala | 15 ++- .../fr/acinq/eclair/swap/SwapHelpers.scala | 26 +++-- .../fr/acinq/eclair/swap/SwapInReceiver.scala | 10 +- .../fr/acinq/eclair/swap/SwapInSender.scala | 107 ++++++------------ .../fr/acinq/eclair/swap/SwapResponses.scala | 4 + .../acinq/eclair/swap/SwapTransactions.scala | 8 +- .../scala/fr/acinq/eclair/io/PeerSpec.scala | 2 +- .../swap/PeerSwapMessageCodecsSpec.scala | 8 +- .../eclair/swap/SwapInReceiverSpec.scala | 2 +- .../acinq/eclair/swap/SwapInSenderSpec.scala | 28 ++--- .../eclair/swap/SwapIntegrationSpec.scala | 65 ++++++++--- .../acinq/eclair/swap/SwapRegisterSpec.scala | 15 +-- .../eclair/swap/SwapTransactionsSpec.scala | 13 ++- 13 files changed, 150 insertions(+), 153 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala index 332edd37ec..20e2c42e55 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala @@ -19,12 +19,12 @@ package fr.acinq.eclair.swap import akka.actor.typed.ActorRef import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpentTriggered, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchOutputSpentTriggered, WatchTxConfirmedTriggered} import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} import fr.acinq.eclair.swap.SwapData._ import fr.acinq.eclair.swap.SwapResponses.{Response, Status} -import fr.acinq.eclair.wire.protocol.{CancelSwap, HasSwapId, OpeningTxBroadcasted} +import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted} object SwapCommands { @@ -37,13 +37,12 @@ object SwapCommands { sealed trait CreateSwapMessages extends SwapCommand case object StateTimeout extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with ClaimSwapCsvMessages with WaitCsvMessages with SendAgreementMessages with ClaimSwapMessages - case class CancelReceived(cancel: CancelSwap) extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with SendAgreementMessages with AwaitOpeningTxConfirmedMessages case class ChannelDataFailure(failure: Register.ForwardFailure[CMD_GET_CHANNEL_DATA]) extends CreateSwapMessages case class ChannelDataResult(channelData: RES_GET_CHANNEL_DATA[ChannelData]) extends CreateSwapMessages sealed trait AwaitAgreementMessages extends SwapCommand - case class SwapMessageReceived(message: HasSwapId) extends AwaitAgreementMessages with AwaitClaimPaymentMessages with SendAgreementMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with ClaimSwapMessages + case class SwapMessageReceived(message: HasSwapId) extends AwaitAgreementMessages with CreateOpeningTxMessages with AwaitClaimPaymentMessages with SendAgreementMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with ClaimSwapMessages case class ForwardFailureAdapter(result: Register.ForwardFailure[HasSwapId]) extends AwaitAgreementMessages sealed trait CreateOpeningTxMessages extends SwapCommand @@ -55,11 +54,11 @@ object SwapCommands { case class RollbackFailure(error: String, exception: Throwable) extends CreateOpeningTxMessages sealed trait AwaitOpeningTxConfirmedMessages extends SwapCommand - case class OpeningTxConfirmed(openingConfirmedTriggered: WatchTxConfirmedTriggered) extends AwaitOpeningTxConfirmedMessages - case object InvoiceExpired extends AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with ClaimSwapCoopMessages + case class OpeningTxConfirmed(openingConfirmedTriggered: WatchTxConfirmedTriggered) extends AwaitOpeningTxConfirmedMessages with ClaimSwapCoopMessages + case object InvoiceExpired extends AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages sealed trait AwaitClaimPaymentMessages extends SwapCommand - case class CsvDelayConfirmed(csvDelayTriggered: WatchTxConfirmedTriggered) extends SwapCommand with WaitCsvMessages + case class CsvDelayConfirmed(csvDelayTriggered: WatchFundingDeeplyBuriedTriggered) extends SwapCommand with WaitCsvMessages case class PaymentEventReceived(paymentEvent: PaymentEvent) extends AwaitClaimPaymentMessages with PayClaimInvoiceMessages sealed trait ClaimSwapCoopMessages extends SwapCommand @@ -92,7 +91,7 @@ object SwapCommands { sealed trait ClaimSwapMessages extends SwapCommand - sealed trait UserMessages extends CreateSwapMessages with SendAgreementMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with SendCoopCloseMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages + sealed trait UserMessages extends SendAgreementMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with SendCoopCloseMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages case class GetStatus(replyTo: ActorRef[Status]) extends UserMessages case class CancelRequested(replyTo: ActorRef[Response]) extends UserMessages // @Formatter:on diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala index d4b4a66121..2ca7fd672d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala @@ -26,7 +26,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpent, WatchOutputSpentTriggered, WatchTxConfirmed, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode @@ -55,12 +55,11 @@ object SwapHelpers { context.messageAdapter[Register.ForwardFailure[CMD_GET_CHANNEL_DATA]](ChannelDataFailure) def receiveSwapMessage[B <: SwapCommand : ClassTag](context: ActorContext[SwapCommand], stateName: String)(f: B => Behavior[SwapCommand]): Behavior[SwapCommand] = { - + context.log.debug(s"$stateName: waiting for messages, context: ${context.self.toString}") Behaviors.receiveMessage { - case m: B => context.log.debug(s"processing message ${m.getClass.getSimpleName} in ${context.self.toString} at state $stateName") + case m: B => context.log.debug(s"$stateName: processing message $m") f(m) - case m => - context.log.error(s"received unhandled message in ${context.self.toString} at state $stateName of ${m.getClass.getSimpleName}") + case m => context.log.error(s"$stateName: received unhandled message $m") Behaviors.same } } @@ -70,6 +69,9 @@ object SwapHelpers { def watchForTxConfirmation(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: ByteVector32, minDepth: Long): Unit = watcher ! WatchTxConfirmed(replyTo, txId, minDepth) + def watchForTxCsvConfirmation(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchFundingDeeplyBuriedTriggered], txId: ByteVector32, minDepth: Long): Unit = + watcher ! WatchFundingDeeplyBuried(replyTo, txId, minDepth) + def watchForOutputSpent(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchOutputSpentTriggered], txId: ByteVector32, outputIndex: Int): Unit = watcher ! WatchOutputSpent(replyTo, txId, outputIndex, Set()) @@ -107,7 +109,8 @@ object SwapHelpers { def commitOpening(wallet: OnChainWallet)(swapId: String, invoice: Bolt11Invoice, fundingResponse: MakeFundingTxResponse, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = { context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, fundingResponse.fundingTx, desc)) context.pipeToSelf(wallet.commit(fundingResponse.fundingTx)) { - case Success(true) => OpeningTxCommitted(invoice, OpeningTxBroadcasted(swapId, invoice.toString, fundingResponse.fundingTx.txid.toHex, fundingResponse.fundingTxOutputIndex, "")) + case Success(true) => context.log.debug(s"opening tx ${fundingResponse.fundingTx.txid} published for swap $swapId") + OpeningTxCommitted(invoice, OpeningTxBroadcasted(swapId, invoice.toString, fundingResponse.fundingTx.txid.toHex, fundingResponse.fundingTxOutputIndex, "")) case Success(false) => OpeningTxFailed("could not publish swap open tx", Some(fundingResponse)) case Failure(t) => OpeningTxFailed(s"failed to commit swap open tx, exception: $t", Some(fundingResponse)) } @@ -116,14 +119,17 @@ object SwapHelpers { def commitClaim(wallet: OnChainWallet)(swapId: String, txInfo: TransactionWithInputInfo, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = checkSpendable(txInfo) match { case Success(_) => - // publish claim by coop tx + // publish claim tx context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, txInfo.tx, desc)) context.pipeToSelf(wallet.commit(txInfo.tx)) { case Success(true) => ClaimTxCommitted - case Success(false) => ClaimTxFailed("could not publish") - case Failure(t) => ClaimTxFailed(s"failed to commit, exception: $t") + case Success(false) => context.log.error(s"swap $swapId claim tx commit did not succeed, $txInfo") + ClaimTxFailed(s"publish did not succeed $txInfo") + case Failure(t) => context.log.error(s"swap $swapId claim tx commit failed, $txInfo") + ClaimTxFailed(s"failed to commit $txInfo, exception: $t") } - case Failure(e) => context.self ! ClaimTxInvalid(e) + case Failure(e) => context.log.error(s"swap $swapId claim tx is invalid: $e") + context.self ! ClaimTxInvalid(e) } def rollback(wallet: OnChainWallet)(error: String, tx: Transaction)(implicit context: ActorContext[SwapCommand]): Unit = diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala index 7419a91c89..1573ff8dbd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala @@ -102,7 +102,7 @@ object SwapInReceiver { } private class SwapInReceiver(request: SwapInRequest, shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { - val protocolVersion = 1 + val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds @@ -130,11 +130,10 @@ private class SwapInReceiver(request: SwapInRequest, shortChannelId: ShortChanne receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) if agreement.protocolVersion == request.protocolVersion && agreement.swapId == swapId => awaitOpeningTxConfirmed(agreement, openingTxBroadcasted) - case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) - case CancelReceived(_) => Behaviors.same + case SwapMessageReceived(cancel: CancelSwap) if cancel.swapId == swapId => swapCanceled(PeerCanceled(swapId)) + case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during sendAgreement: $m") case StateTimeout => swapCanceled(InternalError(swapId, "timeout during sendAgreement")) case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap agreement to peer.")) - case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during sendAgreement: $m") case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) sendCoopClose(s"Cancel requested by user after sending agreement.") case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "sendAgreement", ByteVector32.Zeroes, request, Some(agreement)) @@ -148,9 +147,8 @@ private class SwapInReceiver(request: SwapInRequest, shortChannelId: ShortChanne receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { case OpeningTxConfirmed(opening) => validateOpeningTx(agreement, openingTxBroadcasted, opening.tx) + case SwapMessageReceived(cancel: CancelSwap) if cancel.swapId == swapId => swapCanceled(PeerCanceled(swapId)) case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during awaitOpeningTxConfirmed: $m") - case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) - case CancelReceived(_) => Behaviors.same case InvoiceExpired => sendCoopClose("Timeout waiting for opening tx to confirm.") case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) sendCoopClose(s"Cancel requested by user while waiting for opening tx to confirm.") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala index f3eab33612..eb52b1177b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala @@ -27,15 +27,15 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchTxConfirmedTriggered} import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.channel.{DATA_NORMAL, RES_GET_CHANNEL_DATA} +import fr.acinq.eclair.channel.{DATA_NORMAL, DATA_WAIT_FOR_FUNDING_CONFIRMED, RES_GET_CHANNEL_DATA} import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceivePayment} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapHelpers._ -import fr.acinq.eclair.swap.SwapResponses.{Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapInStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapInStatus, UserCanceled} import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByCoopTx, makeSwapClaimByCsvTx, makeSwapOpeningInputInfo} import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx} @@ -91,14 +91,14 @@ object SwapInSender { .createSwap() case RestoreSwapInSender(d) => new SwapInSender(d.request.amount.sat, d.request.swapId, d.channelId, nodeParams, watcher, register, wallet, context) - .awaitOpeningTxConfirmed(d.request, d.agreement, d.invoice, d.openingTxBroadcasted) + .awaitClaimPayment(d.request, d.agreement, d.invoice, d.openingTxBroadcasted) case AbortSwapInSender => Behaviors.stopped } } } private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVector32, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { - val protocolVersion = 1 + val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds private val keyManager: SwapKeyManager = nodeParams.swapKeyManager @@ -117,14 +117,10 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto case ChannelDataResult(RES_GET_CHANNEL_DATA(channelData)) if channelData.isInstanceOf[DATA_NORMAL] => val shortChannelId = channelData.asInstanceOf[DATA_NORMAL].shortIds.real.toOption.get.toString awaitAgreement(SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId, amount.toLong, makerPubkey().toHex)) - case ChannelDataResult(channelData) => swapCanceled(InternalError(swapId, s"invalid channel: $channelData.")) - case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) - case CancelReceived(_) => Behaviors.same - case StateTimeout => swapCanceled(InternalError(swapId, "timeout during createSwap")) - case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) - swapCanceled(UserCanceled(swapId)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "createSwap", channelId, SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), "unknown", amount.toLong, makerPubkey().toHex)) - Behaviors.same + case ChannelDataResult(RES_GET_CHANNEL_DATA(channelData)) if channelData.isInstanceOf[DATA_WAIT_FOR_FUNDING_CONFIRMED] => + swapCanceled(CreateFailed(swapId, "Channel waiting for funding to be confirmed.")) + case ChannelDataResult(channelData) => swapCanceled(CreateFailed(swapId, s"invalid channel: $channelData.")) + case StateTimeout => swapCanceled(CreateFailed(swapId, "timeout during createSwap")) } } @@ -137,8 +133,7 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto case SwapMessageReceived(agreement: SwapInAgreement) if agreement.premium > maxPremium => swapCanceled(InternalError(swapId, "unacceptable premium requested.")) case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement) - case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) - case CancelReceived(_) => Behaviors.same + case SwapMessageReceived(cancel: CancelSwap) if cancel.swapId == swapId => swapCanceled(PeerCanceled(swapId)) case StateTimeout => swapCanceled(InternalError(swapId, "timeout during awaitAgreement")) case ForwardFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap request to peer.")) case SwapMessageReceived(m) => swapCanceled(InvalidMessage(swapId, "awaitAgreement", m)) @@ -160,13 +155,14 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto // TODO: checkpoint PersistentSwapData for this swap to a database before committing the opening tx case OpeningTxFunded(invoice, fundingResponse) => commitOpening(wallet)(swapId, invoice, fundingResponse, "swap-in-sender-opening") Behaviors.same - case OpeningTxCommitted(invoice, openingTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, invoice, openingTxBroadcasted) + case OpeningTxCommitted(invoice, openingTxBroadcasted) => + awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted) case OpeningTxFailed(error, None) => swapCanceled(InternalError(swapId, s"failed to fund swap open tx, error: $error")) case OpeningTxFailed(error, Some(r)) => rollback(wallet)(error, r.fundingTx) Behaviors.same case RollbackSuccess(error, value) => swapCanceled(InternalError(swapId, s"rollback: Success($value), error: $error")) case RollbackFailure(error, t) => swapCanceled(InternalError(swapId, s"rollback exception: $t, error: $error")) - case CancelReceived(_) => Behaviors.same // ignore + case SwapMessageReceived(_) => Behaviors.same // ignore case StateTimeout => // TODO: are we sure the opening transaction has not yet been committed? should we rollback locked funding outputs? swapCanceled(InternalError(swapId, "timeout during CreateOpeningTx")) @@ -177,30 +173,6 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto } } - def awaitOpeningTxConfirmed(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { - def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) - watchForTxConfirmation(watcher)(openingConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), nodeParams.channelConf.minDepthBlocks) // watch for opening tx to be confirmed - - Behaviors.withTimers { timers => - timers.startSingleTimer(swapInvoiceExpiredTimer(swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) - receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { - case OpeningTxConfirmed(_) => - awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted) - case SwapMessageReceived(coopClose: CoopClose) if coopClose.swapId == swapId => - claimSwapCoop(request, agreement, invoice, openingTxBroadcasted, coopClose) - case SwapMessageReceived(_) => Behaviors.same - case CancelReceived(c) if c.swapId == swapId => waitCsv(request, agreement, invoice, openingTxBroadcasted) - case CancelReceived(_) => Behaviors.same - case InvoiceExpired => - waitCsv(request, agreement, invoice, openingTxBroadcasted) - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") - Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitOpeningTxConfirmed", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) - Behaviors.same - } - } - } - def awaitClaimPayment(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { // TODO: query payment database for received payment watchForPayment(watch = true) // subscribe to be notified of payment events @@ -228,37 +200,35 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto def claimSwapCoop(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, coopClose: CoopClose): Behavior[SwapCommand] = { val takerPrivkey = PrivateKey(ByteVector.fromValidHex(coopClose.privkey)) val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) - val claimByCoopTx = makeSwapClaimByCoopTx(request.amount.sat, makerPrivkey(), takerPrivkey, invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) - val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat, makerPubkey(), takerPrivkey.publicKey, invoice.paymentHash) + val claimByCoopTx = makeSwapClaimByCoopTx(request.amount.sat + agreement.premium.sat, makerPrivkey(), takerPrivkey, invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(), takerPrivkey.publicKey, invoice.paymentHash) def claimByCoopConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) + def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) watchForPayment(watch = false) - commitClaim(wallet)(swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") + watchForTxConfirmation(watcher)(openingConfirmedAdapter, openingTxId, 1) // watch for opening tx to be confirmed - Behaviors.withTimers { timers => - timers.startSingleTimer(swapInvoiceExpiredTimer(swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) - receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { - case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) - Behaviors.same - case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByCoopConfirmed(swapId, confirmedTriggered)) - case ClaimTxFailed(error) => context.log.error(s"swap $swapId coop claim tx failed, error: $error") - waitCsv(request, agreement, invoice, openingTxBroadcasted) - case ClaimTxInvalid(e) => context.log.error(s"swap $swapId coop claim tx is invalid: $e, tx: $claimByCoopTx") - waitCsv(request, agreement, invoice, openingTxBroadcasted) - case InvoiceExpired => - waitCsv(request, agreement, invoice, openingTxBroadcasted) - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") - Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwapCoop", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) - Behaviors.same - } + receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { + case OpeningTxConfirmed(_) => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) + commitClaim(wallet)(swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") + Behaviors.same + case ClaimTxCommitted => Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => + swapCompleted(ClaimByCoopConfirmed(swapId, confirmedTriggered)) + case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) + case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) + case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwapCoop", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same } } def waitCsv(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { // TODO: are we sure the opening transaction has been committed? should we rollback locked funding outputs? - def csvDelayConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](CsvDelayConfirmed) - watchForTxConfirmation(watcher)(csvDelayConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), claimByCsvDelta.toInt) // watch for opening tx to be buried enough that it can be claimed by csv + def csvDelayConfirmedAdapter: ActorRef[WatchFundingDeeplyBuriedTriggered] = context.messageAdapter[WatchFundingDeeplyBuriedTriggered](CsvDelayConfirmed) + watchForPayment(watch = false) + watchForTxCsvConfirmation(watcher)(csvDelayConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), claimByCsvDelta.toInt) // watch for opening tx to be buried enough that it can be claimed by csv receiveSwapMessage[WaitCsvMessages](context, "waitCsv") { case CsvDelayConfirmed(_) => @@ -275,21 +245,18 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto def claimSwapCsv(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) - val claimByCsvTx = makeSwapClaimByCsvTx(request.amount.sat, makerPrivkey(), takerPubkey(agreement), invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) - val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat, makerPubkey(), takerPubkey(agreement), invoice.paymentHash) + val claimByCsvTx = makeSwapClaimByCsvTx(request.amount.sat + agreement.premium.sat, makerPrivkey(), takerPubkey(agreement), invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(), takerPubkey(agreement), invoice.paymentHash) def claimByCsvConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) - watchForPayment(watch = false) commitClaim(wallet)(swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "swap-in-sender-claimByCsvTx") receiveSwapMessage[ClaimSwapCsvMessages](context, "claimSwapCsv") { case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCsvConfirmedAdapter, claimByCsvTx.txid, nodeParams.channelConf.minDepthBlocks) Behaviors.same case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByCsvConfirmed(swapId, confirmedTriggered)) - case ClaimTxFailed(error) => context.log.error(s"swap $swapId csv claim tx failed, error: $error") - waitCsv(request, agreement, invoice, openingTxBroadcasted) - case ClaimTxInvalid(e) => context.log.error(s"swap $swapId csv claim tx is invalid: $e, tx: $claimByCsvTx") - waitCsv(request, agreement, invoice, openingTxBroadcasted) + case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) + case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) case StateTimeout => // TODO: handle when claim tx not confirmed, resubmit the tx? Behaviors.same diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala index 64e821fcbb..98c47e9f82 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala @@ -44,6 +44,10 @@ object SwapResponses { override def toString: String = s"swap $swapId canceled by peer." } + case class CreateFailed(swapId: String, reason: String) extends Fail { + override def toString: String = s"could not create swap $swapId: $reason." + } + case class InvalidMessage(swapId: String, behavior: String, message: HasSwapId) extends Fail { override def toString: String = s"swap $swapId canceled due to invalid message during $behavior: $message." } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala index eeb88b7ddc..6d13c6b20f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala @@ -44,7 +44,7 @@ object SwapTransactions { def makeSwapOpeningInputInfo(fundingTxId: ByteVector32, fundingTxOutputIndex: Int, amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): InputInfo = { val redeemScript = swapOpening(makerPubkey, takerPubkey, paymentHash) val openingTxOut = makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash) - InputInfo(OutPoint(fundingTxId, fundingTxOutputIndex), openingTxOut, write(redeemScript)) + InputInfo(OutPoint(fundingTxId.reverse, fundingTxOutputIndex), openingTxOut, write(redeemScript)) } def makeSwapOpeningTxOut(amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): TxOut = { @@ -73,7 +73,7 @@ object SwapTransactions { val tx = Transaction( version = 2, - txIn = TxIn(OutPoint(openingTxId, openingOutIndex), ByteVector.empty, 0) :: Nil, + txIn = TxIn(OutPoint(openingTxId.reverse, openingOutIndex), ByteVector.empty, 0) :: Nil, txOut = TxOut(0 sat, pay2wpkh(takerPrivkey.publicKey)) :: Nil, lockTime = 0) @@ -104,7 +104,7 @@ object SwapTransactions { val tx = Transaction( version = 2, - txIn = TxIn(OutPoint(openingTxId, openingOutIndex), ByteVector.empty, 0) :: Nil, + txIn = TxIn(OutPoint(openingTxId.reverse, openingOutIndex), ByteVector.empty, 0) :: Nil, txOut = TxOut(0 sat, pay2wpkh(makerPrivkey.publicKey)) :: Nil, lockTime = 0) @@ -140,7 +140,7 @@ object SwapTransactions { val tx = Transaction( version = 2, - txIn = TxIn(OutPoint(openingTxId, openingOutIndex), ByteVector.empty, claimByCsvDelta.toInt) :: Nil, + txIn = TxIn(OutPoint(openingTxId.reverse, openingOutIndex), ByteVector.empty, claimByCsvDelta.toInt) :: Nil, txOut = TxOut(0 sat, pay2wpkh(makerPrivkey.publicKey)) :: Nil, lockTime = 0) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala index f56f7cb9a8..e49d5fba0c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala @@ -593,7 +593,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) - val protocolVersion = 1 + val protocolVersion = 2 val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" val premium = 10 val responderPubkey = randomKey().publicKey diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala index c351e1cef4..b8c952c0e4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala @@ -28,7 +28,7 @@ class PeerSwapMessageCodecsSpec extends PeerSwapSpec { test("encode/decode SwapInRequest messages to/from binary") { val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","asset":"$asset","network":"$network","scid":"$shortId","amount":$amount,"pubkey":"$pubkey"}""".stripMargin - val bin = hex"a4557b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + val bin = hex"0xa4557b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" val obj = SwapInRequest(protocolVersion, swapId.toHex, asset, network, shortId.toString, amount, pubkey.toString()) val encoded = peerSwapMessageCodecWithFallback.encode(obj).require val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require @@ -42,7 +42,7 @@ class PeerSwapMessageCodecsSpec extends PeerSwapSpec { test("encode/decode SwapOutRequest messages to/from binary") { val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","asset":"$asset","network":"$network","scid":"$shortId","amount":$amount,"pubkey":"$pubkey"}""".stripMargin val obj = SwapOutRequest(protocolVersion, swapId.toHex, asset, network, shortId.toString, amount, pubkey.toString()) - val bin = hex"a4577b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + val bin = hex"a4577b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" val encoded = peerSwapMessageCodecWithFallback.encode(obj).require val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require @@ -55,7 +55,7 @@ class PeerSwapMessageCodecsSpec extends PeerSwapSpec { test("encode/decode SwapInAgreement messages to/from binary") { val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","pubkey":"$pubkey","premium":$premium}""".stripMargin val obj = SwapInAgreement(protocolVersion = protocolVersion, swapId = swapId.toHex, pubkey = pubkey.toString, premium = premium) - val bin = hex"a4597b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c227072656d69756d223a313030307d" + val bin = hex"a4597b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c227072656d69756d223a313030307d" val encoded = peerSwapMessageCodecWithFallback.encode(obj).require val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require @@ -68,7 +68,7 @@ class PeerSwapMessageCodecsSpec extends PeerSwapSpec { test("encode/decode SwapOutAgreement messages to/from binary") { val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","pubkey":"$pubkey","payreq":"$payreq"}""".stripMargin val obj = SwapOutAgreement(protocolVersion = protocolVersion, swapId = swapId.toHex, pubkey = pubkey.toString, payreq = payreq) - val bin = hex"a45b7b2270726f746f636f6c5f76657273696f6e223a312c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c22706179726571223a22696e766f6963652068657265227d" + val bin = hex"a45b7b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c22706179726571223a22696e766f6963652068657265227d" val encoded = peerSwapMessageCodecWithFallback.encode(obj).require val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala index e1869e142f..f08ebb9c58 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala @@ -51,7 +51,7 @@ import scala.concurrent.duration._ // with BitcoindService case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { override implicit val timeout: Timeout = Timeout(30 seconds) - val protocolVersion = 1 + val protocolVersion = 2 val noAsset = "" val network: String = NodeParams.chainFromHash(TestConstants.Bob.nodeParams.chainHash) val amount: Satoshi = 1000 sat diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala index 575a567b48..486071da14 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala @@ -49,7 +49,7 @@ import scala.concurrent.{ExecutionContext, Future} // with BitcoindService case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { override implicit val timeout: Timeout = Timeout(30 seconds) - val protocolVersion = 1 + val protocolVersion = 2 val noAsset = "" val network: String = Block.RegtestGenesisBlock.hash.toString() val amount: Satoshi = 1000 sat @@ -104,9 +104,6 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) swapInSender ! RestoreSwapInSender(swapData) - // SwapInSender confirms opening tx on-chain - watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) - // resend OpeningTxBroadcasted when swap restored register.expectMessageType[Forward[OpeningTxBroadcasted]] @@ -147,13 +144,8 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // SwapInReceiver: SwapInAgreement -> SwapInSender swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.message.protocolVersion, swapInRequest.message.swapId, takerPubkey.toString(), premium)) - // SwapInSender confirms opening tx on-chain + // SwapInSender publishes opening tx on-chain val openingTx = swapEvents.expectMessageType[TransactionPublished].tx - watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx) - - // SwapInSender reports status of awaiting payment - swapInSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitClaimPayment") // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver val openingTxBroadcasted = register.expectMessageType[Forward[OpeningTxBroadcasted]] @@ -162,6 +154,10 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() + // SwapInSender reports status of awaiting payment + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitClaimPayment") + // SwapInSender receives a payment with the corresponding payment hash // TODO: convert from ShortChannelId to ByteVector32 val paymentReceived = PaymentReceived(invoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) @@ -183,9 +179,6 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) swapInSender ! RestoreSwapInSender(swapData) - // SwapInSender confirms opening tx on-chain - watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) - // resend OpeningTxBroadcasted when swap restored register.expectMessageType[Forward[OpeningTxBroadcasted]] @@ -194,7 +187,9 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // SwapInReceiver: CoopClose -> SwapInSender swapInSender ! SwapMessageReceived(CoopClose(swapId, "oops", takerPrivkey.toHex)) - watcher.expectMessageType[WatchTxConfirmed] + + // SwapInSender confirms that opening tx on-chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) // SwapInSender reports status of awaiting claim by cooperative close tx to confirm swapInSender ! GetStatus(userCli.ref) @@ -221,9 +216,6 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) swapInSender ! RestoreSwapInSender(swapData) - // watch for and trigger that the opening tx has been confirmed on-chain - watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(0), 0, Transaction(2, Seq(), Seq(), 0)) - // resend OpeningTxBroadcasted when swap restored register.expectMessageType[Forward[OpeningTxBroadcasted]] @@ -231,7 +223,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo swapEvents.expectNoMessage() // watch for and trigger that the opening tx has been buried by csv delay blocks - watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(0), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0)) + watcher.expectMessageType[WatchFundingDeeplyBuried].replyTo ! WatchFundingDeeplyBuriedTriggered(BlockHeight(0), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0)) // SwapInSender reports status of awaiting claim by csv tx to confirm swapInSender ! GetStatus(userCli.ref) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala index 65e88ec61c..a188d34fcb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala @@ -12,7 +12,7 @@ import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture import fr.acinq.eclair.integration.basic.fixtures.composite.TwoNodesFixture import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentSent} import fr.acinq.eclair.swap.SwapEvents._ -import fr.acinq.eclair.swap.SwapRegister.{ListPendingSwaps, SwapInRequested} +import fr.acinq.eclair.swap.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapInRequested} import fr.acinq.eclair.swap.SwapResponses.{Status, SwapOpened} import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta import fr.acinq.eclair.swap.SwapTransactions.claimByInvoiceTxWeight @@ -93,10 +93,8 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId - // swap in sender (bob) confirms opening tx on-chain + // swap in sender (bob) confirms opening tx published val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx - bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) - assert(openingTx.txOut.head.amount == amount + premium) // bob has status of 1 pending swap bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) @@ -106,12 +104,13 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // swap in receiver (alice) confirms opening tx on-chain alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + assert(openingTx.txOut.head.amount == amount + premium) // swap in receiver (alice) sends a payment of `amount` to swap in sender (bob) assert(aliceSwap.paymentEvents.expectMsgType[PaymentSent].recipientAmount === toMilliSatoshi(amount)) assert(bobSwap.paymentEvents.expectMsgType[PaymentReceived].amount === toMilliSatoshi(amount)) - // swap in receiver (alice) confirms claim-by-invoice tx on-chain + // swap in receiver (alice) confirms claim-by-invoice tx published val claimTx = aliceSwap.swapEvents.expectMsgType[TransactionPublished].tx assert(claimTx.txOut.head.amount == amount) // added on-chain premium consumed as tx fee alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByInvoiceBlock, 0, claimTx) @@ -139,9 +138,8 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId - // swap in sender (bob) confirms opening tx on-chain + // swap in sender (bob) confirms opening tx published val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx - bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) assert(openingTx.txOut.head.amount == amount + premium) // bob has status of 1 pending swap @@ -159,7 +157,10 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // swap in receiver (alice) confirms opening tx on-chain alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) - // swap in sender (bob) confirms claim-by-coop tx on-chain + // swap in sender (bob) confirms opening tx on-chain before publishing claim-by-coop tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + + // swap in sender (bob) confirms claim-by-coop tx published and confirmed on-chain val claimTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCoopBlock, 0, claimTx) @@ -192,16 +193,13 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId - // swap in sender (bob) confirms opening tx on-chain + // swap in sender (bob) confirms opening tx published val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount + premium) // swap in receiver (alice) stops unexpectedly alice.swapRegister ! Kill - // opening tx confirmed - bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) - assert(openingTx.txOut.head.amount == amount + premium) - // bob has status of 1 pending swap bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] @@ -209,9 +207,9 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(bobStatus.head.swapId === swapId) // opening tx buried by csv delay - bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCsvBlock, 0, openingTx) + bob.watcher.expectMsgType[WatchFundingDeeplyBuried].replyTo ! WatchFundingDeeplyBuriedTriggered(claimByCsvBlock, 0, openingTx) - // swap in sender (bob) confirms claim-by-csv tx on-chain if Alice does not send payment + // swap in sender (bob) confirms claim-by-csv tx published and confirmed if Alice does not send payment val claimTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCsvBlock, 0, claimTx) @@ -219,4 +217,41 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(bobSwap.swapEvents.expectMsgType[ClaimByCsvConfirmed].swapId == swapId) } + test("swap in - claim by coop, receiver cancels while waiting for opening tx to confirm") { f => + import f._ + + val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val channelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByCoopBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx is published, but NOT yet confirmed on-chain + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + + // swap in receiver (alice) sends CoopClose before the opening tx has been confirmed on-chain + alice.swapRegister ! CancelSwapRequested(aliceSwap.cli.ref, swapId) + val claimByCoopEvent = aliceSwap.swapEvents.expectMsgType[ClaimByCoopOffered] + assert(claimByCoopEvent.swapId == swapId) + + // swap in sender (bob) watches for opening tx to be confirmed in a block before publishing the claim by coop tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + + // swap in sender (bob) confirms claim by coop tx published and confirmed on-chain + val claimByCoopTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCoopBlock, 0, claimByCoopTx) + + // swap in sender (bob) confirms claim-by-coop + assert(bobSwap.swapEvents.expectMsgType[ClaimByCoopConfirmed].swapId == swapId) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala index 42754c5d96..f990885ab6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala @@ -23,10 +23,9 @@ import akka.actor.typed.scaladsl.adapter._ import akka.util.Timeout import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchTxConfirmed, WatchTxConfirmedTriggered} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} import fr.acinq.eclair.channel.Register.Forward @@ -38,7 +37,7 @@ import fr.acinq.eclair.swap.SwapRegister.{MessageReceived, SwapInRequested, Swap import fr.acinq.eclair.swap.SwapResponses.{Response, SwapOpened} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} -import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} import org.mockito.scalatest.IdiomaticMockito import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.matchers.should.Matchers @@ -50,7 +49,7 @@ import scala.concurrent.{ExecutionContext, Future} class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with BeforeAndAfterAll with Matchers with FixtureAnyFunSuiteLike with IdiomaticMockito with ParallelTestExecution { override implicit val timeout: Timeout = Timeout(30 seconds) - val protocolVersion = 1 + val protocolVersion = 2 val noAsset = "" val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) val amount: Satoshi = 1000 sat @@ -98,9 +97,6 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val savedData: Set[SwapInSenderData] = Set(SwapInSenderData(channelId, swapInRequest, swapInAgreement, invoice, openingTxBroadcasted)) val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, savedData)), "SwapRegister") - // SwapInSender confirms opening tx on-chain - watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) - // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -143,9 +139,8 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app swapRegister ! MessageReceived(SwapInAgreement(swapInRequest.message.protocolVersion, swapInRequest.message.swapId, bobPayoutPubkey.toString(), premium)) monitor.expectMessageType[MessageReceived] - // SwapInSender confirms opening tx on-chain - val transactionPublishedEvent = swapEvents.expectMessageType[TransactionPublished] - watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, transactionPublishedEvent.tx) + // SwapInSender confirms opening tx published + swapEvents.expectMessageType[TransactionPublished] // Alice:OpeningTxBroadcasted -> Bob val openingTxBroadcasted = register.expectMessageType[Forward[OpeningTxBroadcasted]] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala index beb6463832..0e14d6d432 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala @@ -50,6 +50,7 @@ class SwapTransactionsSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val paymentPreimage: ByteVector32 = randomBytes32() val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage) val amount: Satoshi = 30000 sat + val premium: Satoshi = 150 sat val openingTxId: ByteVector32 = randomBytes32() val openingTxOut: Int = 0 val claimInput: Transactions.InputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxOut, amount, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) @@ -83,22 +84,22 @@ class SwapTransactionsSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val f = createFixture() import f._ - val swapTxOut = makeSwapOpeningTxOut(amount, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) - wallet.makeFundingTx(swapTxOut.publicKeyScript, amount, feeratePerKw).pipeTo(probe.ref) + val swapTxOut = makeSwapOpeningTxOut(amount + premium, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + wallet.makeFundingTx(swapTxOut.publicKeyScript, amount + premium, feeratePerKw).pipeTo(probe.ref) val response = probe.expectMsgType[MakeFundingTxResponse] val openingTx = response.fundingTx val openingTxOut = response.fundingTxOutputIndex - val inputInfo = makeSwapOpeningInputInfo(openingTx.hash, openingTxOut, amount, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + val inputInfo = makeSwapOpeningInputInfo(openingTx.txid, openingTxOut, amount + premium, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) - val swapClaimByInvoiceTx = makeSwapClaimByInvoiceTx(amount, makerRefundPriv.publicKey, takerPaymentPriv, paymentPreimage, feeratePerKw, openingTx.hash, openingTxOut) + val swapClaimByInvoiceTx = makeSwapClaimByInvoiceTx(amount + premium, makerRefundPriv.publicKey, takerPaymentPriv, paymentPreimage, feeratePerKw, openingTx.txid, openingTxOut) assert(swapClaimByInvoiceTx.txIn.head.sequence == 0) assert(checkSpendable(SwapClaimByInvoiceTx(inputInfo, swapClaimByInvoiceTx)).isSuccess) - val swapClaimByCoopTx = makeSwapClaimByCoopTx(amount, makerRefundPriv, takerPaymentPriv, paymentHash, feeratePerKw, openingTx.hash, openingTxOut) + val swapClaimByCoopTx = makeSwapClaimByCoopTx(amount + premium, makerRefundPriv, takerPaymentPriv, paymentHash, feeratePerKw, openingTx.txid, openingTxOut) assert(swapClaimByCoopTx.txIn.head.sequence == 0) assert(checkSpendable(SwapClaimByCoopTx(inputInfo, swapClaimByCoopTx)).isSuccess) - val swapClaimByCsvTx = makeSwapClaimByCsvTx(amount, makerRefundPriv, takerPaymentPriv.publicKey, paymentHash, feeratePerKw, openingTx.hash, openingTxOut) + val swapClaimByCsvTx = makeSwapClaimByCsvTx(amount + premium, makerRefundPriv, takerPaymentPriv.publicKey, paymentHash, feeratePerKw, openingTx.txid, openingTxOut) assert(swapClaimByCsvTx.txIn.head.sequence == 1008) assert(checkSpendable(SwapClaimByCsvTx(inputInfo, swapClaimByCsvTx)).isSuccess) } From d8c41d237e6ecc565cc83c79479840ffd3220228 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 14 Sep 2022 10:08:12 +0200 Subject: [PATCH 14/23] Add swap-out support and basic tests - use only shortChannelId, not channelId - add isInitiator so same SwapData class works for both senders and receivers --- .../main/scala/fr/acinq/eclair/Eclair.scala | 11 +- .../fr/acinq/eclair/swap/SwapCommands.scala | 36 +-- .../scala/fr/acinq/eclair/swap/SwapData.scala | 8 +- .../fr/acinq/eclair/swap/SwapHelpers.scala | 38 ++- .../fr/acinq/eclair/swap/SwapInReceiver.scala | 239 ++++++++++++------ .../fr/acinq/eclair/swap/SwapInSender.scala | 224 ++++++++++------ .../fr/acinq/eclair/swap/SwapRegister.scala | 70 +++-- .../fr/acinq/eclair/swap/SwapResponses.scala | 20 +- .../acinq/eclair/swap/SwapTransactions.scala | 3 +- .../wire/protocol/LightningMessageTypes.scala | 28 +- .../eclair/swap/SwapInReceiverSpec.scala | 20 +- .../acinq/eclair/swap/SwapInSenderSpec.scala | 31 +-- .../eclair/swap/SwapIntegrationSpec.scala | 76 +++++- .../eclair/swap/SwapOutReceiverSpec.scala | 141 +++++++++++ .../acinq/eclair/swap/SwapOutSenderSpec.scala | 176 +++++++++++++ .../acinq/eclair/swap/SwapRegisterSpec.scala | 18 +- .../acinq/eclair/api/handlers/PeerSwap.scala | 10 +- 17 files changed, 855 insertions(+), 294 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 7d2cae4a2c..0a59c16ea0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -166,7 +166,9 @@ trait Eclair { def stop(): Future[Unit] - def swapIn(channelId: ByteVector32, amount: Satoshi)(implicit timeout: Timeout): Future[Response] + def swapIn(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] + + def swapOut(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] @@ -589,8 +591,11 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { Future.successful(()) } - override def swapIn(channelId: ByteVector32, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = - appKit.swapRegister.ask(ref => SwapRegister.SwapInRequested(ref, amount, channelId))(timeout, appKit.system.scheduler.toTyped) + override def swapIn(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = + appKit.swapRegister.ask(ref => SwapRegister.SwapInRequested(ref, amount, shortChannelId))(timeout, appKit.system.scheduler.toTyped) + + override def swapOut(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = + appKit.swapRegister.ask(ref => SwapRegister.SwapOutRequested(ref, amount, shortChannelId))(timeout, appKit.system.scheduler.toTyped) override def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] = appKit.swapRegister.ask(ref => SwapRegister.ListPendingSwaps(ref))(timeout, appKit.system.scheduler.toTyped) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala index 20e2c42e55..1109adf8d9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala @@ -17,32 +17,34 @@ package fr.acinq.eclair.swap import akka.actor.typed.ActorRef -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.eclair.ShortChannelId import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchOutputSpentTriggered, WatchTxConfirmedTriggered} import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} import fr.acinq.eclair.swap.SwapData._ import fr.acinq.eclair.swap.SwapResponses.{Response, Status} -import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted} +import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInRequest, SwapOutRequest} object SwapCommands { sealed trait SwapCommand // @formatter:off - case class StartSwapInSender(amount: Satoshi, swapId: String, channelId: ByteVector32) extends SwapCommand - case class RestoreSwapInSender(swapData: SwapInSenderData) extends SwapCommand + case class StartSwapInSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand + case class StartSwapOutReceiver(request: SwapOutRequest) extends SwapCommand + case class RestoreSwapInSender(swapData: SwapData) extends SwapCommand case object AbortSwapInSender extends SwapCommand sealed trait CreateSwapMessages extends SwapCommand - case object StateTimeout extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with ClaimSwapCsvMessages with WaitCsvMessages with SendAgreementMessages with ClaimSwapMessages - case class ChannelDataFailure(failure: Register.ForwardFailure[CMD_GET_CHANNEL_DATA]) extends CreateSwapMessages + case object StateTimeout extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with ClaimSwapCsvMessages with WaitCsvMessages with AwaitFeePaymentMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages + case class ChannelDataFailure(failure: Register.ForwardShortIdFailure[CMD_GET_CHANNEL_DATA]) extends CreateSwapMessages case class ChannelDataResult(channelData: RES_GET_CHANNEL_DATA[ChannelData]) extends CreateSwapMessages sealed trait AwaitAgreementMessages extends SwapCommand - case class SwapMessageReceived(message: HasSwapId) extends AwaitAgreementMessages with CreateOpeningTxMessages with AwaitClaimPaymentMessages with SendAgreementMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with ClaimSwapMessages + case class SwapMessageReceived(message: HasSwapId) extends AwaitAgreementMessages with CreateOpeningTxMessages with AwaitClaimPaymentMessages with AwaitFeePaymentMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages case class ForwardFailureAdapter(result: Register.ForwardFailure[HasSwapId]) extends AwaitAgreementMessages sealed trait CreateOpeningTxMessages extends SwapCommand @@ -55,11 +57,11 @@ object SwapCommands { sealed trait AwaitOpeningTxConfirmedMessages extends SwapCommand case class OpeningTxConfirmed(openingConfirmedTriggered: WatchTxConfirmedTriggered) extends AwaitOpeningTxConfirmedMessages with ClaimSwapCoopMessages - case object InvoiceExpired extends AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages + case object InvoiceExpired extends AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with AwaitFeePaymentMessages sealed trait AwaitClaimPaymentMessages extends SwapCommand case class CsvDelayConfirmed(csvDelayTriggered: WatchFundingDeeplyBuriedTriggered) extends SwapCommand with WaitCsvMessages - case class PaymentEventReceived(paymentEvent: PaymentEvent) extends AwaitClaimPaymentMessages with PayClaimInvoiceMessages + case class PaymentEventReceived(paymentEvent: PaymentEvent) extends AwaitClaimPaymentMessages with PayClaimInvoiceMessages with AwaitFeePaymentMessages with PayFeeInvoiceMessages sealed trait ClaimSwapCoopMessages extends SwapCommand case object ClaimTxCommitted extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages @@ -73,12 +75,14 @@ object SwapCommands { // @Formatter:on // @formatter:off - case object StartSwapInReceiver extends SwapCommand - case class RestoreSwapInReceiver(swapData: SwapInReceiverData) extends SwapCommand + case class StartSwapInReceiver(request: SwapInRequest) extends SwapCommand + case class StartSwapOutSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand + case class RestoreSwapInReceiver(swapData: SwapData) extends SwapCommand case object AbortSwapInReceiver extends SwapCommand sealed trait SendAgreementMessages extends SwapCommand - case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[HasSwapId]) extends SendAgreementMessages with SendCoopCloseMessages + sealed trait AwaitFeePaymentMessages extends SwapCommand + case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[HasSwapId]) extends AwaitFeePaymentMessages with SendCoopCloseMessages with SendAgreementMessages sealed trait ValidateTxMessages extends SwapCommand case class ValidInvoice(invoice: Bolt11Invoice) extends ValidateTxMessages @@ -91,8 +95,10 @@ object SwapCommands { sealed trait ClaimSwapMessages extends SwapCommand - sealed trait UserMessages extends SendAgreementMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with SendCoopCloseMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages - case class GetStatus(replyTo: ActorRef[Status]) extends UserMessages - case class CancelRequested(replyTo: ActorRef[Response]) extends UserMessages + sealed trait PayFeeInvoiceMessages extends SwapCommand + + sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with SendCoopCloseMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages + case class GetStatus(replyTo: ActorRef[Status]) extends UserMessages with PayFeeInvoiceMessages with SendAgreementMessages + case class CancelRequested(replyTo: ActorRef[Response]) extends UserMessages with PayFeeInvoiceMessages with SendAgreementMessages // @Formatter:on } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala index 4920b75f40..c7aa2b7aa4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala @@ -16,13 +16,9 @@ package fr.acinq.eclair.swap -import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.eclair.payment.Bolt11Invoice -import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapAgreement, SwapRequest} object SwapData { - - final case class SwapInSenderData(channelId: ByteVector32, request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) - - final case class SwapInReceiverData(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) + final case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala index 2ca7fd672d..c8eaff12f1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala @@ -22,37 +22,38 @@ import akka.actor.typed.scaladsl.adapter.TypedActorRefOps import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, Transaction} +import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} +import fr.acinq.eclair.db.PaymentType import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents.TransactionPublished import fr.acinq.eclair.swap.SwapTransactions.makeSwapOpeningTxOut import fr.acinq.eclair.transactions.Transactions.{TransactionWithInputInfo, checkSpendable} -import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} -import fr.acinq.eclair.{NodeParams, ShortChannelId} -import scodec.bits.ByteVector +import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted} +import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond, randomBytes32} import scala.concurrent.ExecutionContext.Implicits.global import scala.reflect.ClassTag -import scala.util.{Failure, Success} +import scala.util.{Failure, Success, Try} object SwapHelpers { - def queryChannelData(register: actor.ActorRef, channelId: ByteVector32)(implicit context: ActorContext[SwapCommand]): Unit = - register ! Register.Forward[CMD_GET_CHANNEL_DATA](channelDataFailureAdapter(context), channelId, CMD_GET_CHANNEL_DATA(channelDataResultAdapter(context).toClassic)) + def queryChannelData(register: actor.ActorRef, shortChannelId: ShortChannelId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.ForwardShortId[CMD_GET_CHANNEL_DATA](channelDataFailureAdapter(context), shortChannelId, CMD_GET_CHANNEL_DATA(channelDataResultAdapter(context).toClassic)) def channelDataResultAdapter(context: ActorContext[SwapCommand]): ActorRef[RES_GET_CHANNEL_DATA[ChannelData]] = context.messageAdapter[RES_GET_CHANNEL_DATA[ChannelData]](ChannelDataResult) - def channelDataFailureAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[CMD_GET_CHANNEL_DATA]] = - context.messageAdapter[Register.ForwardFailure[CMD_GET_CHANNEL_DATA]](ChannelDataFailure) + def channelDataFailureAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortIdFailure[CMD_GET_CHANNEL_DATA]] = + context.messageAdapter[Register.ForwardShortIdFailure[CMD_GET_CHANNEL_DATA]](ChannelDataFailure) def receiveSwapMessage[B <: SwapCommand : ClassTag](context: ActorContext[SwapCommand], stateName: String)(f: B => Behavior[SwapCommand]): Behavior[SwapCommand] = { context.log.debug(s"$stateName: waiting for messages, context: ${context.self.toString}") @@ -66,6 +67,8 @@ object SwapHelpers { def swapInvoiceExpiredTimer(swapId: String): String = "swap-invoice-expired-timer-" + swapId + def swapFeeExpiredTimer(swapId: String): String = "swap-fee-expired-timer-" + swapId + def watchForTxConfirmation(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: ByteVector32, minDepth: Long): Unit = watcher ! WatchTxConfirmed(replyTo, txId, minDepth) @@ -96,11 +99,11 @@ object SwapHelpers { def forwardAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[HasSwapId]] = context.messageAdapter[Register.ForwardFailure[HasSwapId]](ForwardFailureAdapter) - def fundOpening(wallet: OnChainWallet, feeRatePerKw: FeeratePerKw)(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand]): Unit = { + def fundOpening(wallet: OnChainWallet, feeRatePerKw: FeeratePerKw)(amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand]): Unit = { // setup conditions satisfied, create the opening tx - val openingTx = makeSwapOpeningTxOut((request.amount + agreement.premium).sat, PublicKey(ByteVector.fromValidHex(request.pubkey)), PublicKey(ByteVector.fromValidHex(agreement.pubkey)), invoice.paymentHash) + val openingTx = makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, invoice.paymentHash) // funding successful, commit the opening tx - context.pipeToSelf(wallet.makeFundingTx(openingTx.publicKeyScript, (request.amount + agreement.premium).sat, feeRatePerKw)) { + context.pipeToSelf(wallet.makeFundingTx(openingTx.publicKeyScript, amount, feeRatePerKw)) { case Success(r) => OpeningTxFunded(invoice, r) case Failure(cause) => OpeningTxFailed(s"error while funding swap open tx: $cause") } @@ -137,4 +140,15 @@ object SwapHelpers { case Success(status) => RollbackSuccess(error, status) case Failure(t) => RollbackFailure(error, t) } + + def createInvoice(nodeParams: NodeParams, amount: Satoshi, description: String)(implicit context: ActorContext[SwapCommand]): Try[Bolt11Invoice] = + Try { + val paymentPreimage = randomBytes32() + val invoice: Bolt11Invoice = Bolt11Invoice(nodeParams.chainHash, Some(toMilliSatoshi(amount)), Crypto.sha256(paymentPreimage), nodeParams.privateKey, Left(description), + nodeParams.channelConf.minFinalExpiryDelta, fallbackAddress = None, expirySeconds = Some(nodeParams.invoiceExpiry.toSeconds), + extraHops = Nil, timestamp = TimestampSecond.now(), paymentSecret = paymentPreimage, paymentMetadata = None, features = nodeParams.features.invoiceFeatures()) + context.log.debug("generated invoice={} from amount={} sat, description={}", invoice.toString, amount, description) + nodeParams.db.payments.addIncomingPayment(invoice, paymentPreimage, PaymentType.Standard) + invoice + } } \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala index 1573ff8dbd..509ee0b557 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala @@ -22,7 +22,7 @@ import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} import akka.util.Timeout import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpentTriggered, WatchTxConfirmedTriggered} @@ -31,8 +31,8 @@ import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentFailed, Paym import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapHelpers._ -import fr.acinq.eclair.swap.SwapResponses.{Error, Fail, InternalError, PeerCanceled, SwapError, SwapInStatus, UserCanceled} -import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningInputInfo, validOpeningTx} +import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapInStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions.SwapClaimByCoopTx import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{NodeParams, ShortChannelId, ToMilliSatoshiConversion} @@ -45,6 +45,25 @@ object SwapInReceiver { /* SwapInSender SwapInReceiver + RESPONDER INITIATOR + | | [createSwap] + | SwapOutRequest | + |<-------------------------------| + [validateRequest] | | [awaitAgreement] + | | + | SwapOutAgreement | + |------------------------------->| + [awaitFeePayment] | | [validateFeeInvoice] + | | + | | [payFeeInvoice] + |<------------------------------>| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + INITIATOR RESPONDER [createSwap] | | | SwapInRequest | @@ -79,21 +98,24 @@ object SwapInReceiver { */ - def apply(request: SwapInRequest, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet): Behavior[SwapCommand] = + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet): Behavior[SwapCommand] = Behaviors.setup { context => Behaviors.receiveMessagePartial { - case StartSwapInReceiver => + case StartSwapOutSender(amount, swapId, shortChannelId) => + new SwapInReceiver(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + .createSwap(amount, swapId) + case StartSwapInReceiver(request: SwapInRequest) => ShortChannelId.fromCoordinates(request.scid) match { - case Success(shortChannelId) => new SwapInReceiver(request, shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) - .validateRequest() + case Success(shortChannelId) => new SwapInReceiver(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + .validateRequest(request) case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped } case RestoreSwapInReceiver(d) => ShortChannelId.fromCoordinates(d.request.scid) match { - case Success(shortChannelId) => new SwapInReceiver(d.request, shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) - .awaitOpeningTxConfirmed(d.agreement, d.openingTxBroadcasted) - case Failure(e) => context.log.error(s"could not restore swap request with invalid shortChannelId: $request, $e") + case Success(shortChannelId) => new SwapInReceiver(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + .awaitOpeningTxConfirmed(d.request, d.agreement, d.openingTxBroadcasted, d.isInitiator) + case Failure(e) => context.log.error(s"could not restore swap receiver with invalid shortChannelId: $d, $e") Behaviors.stopped } case AbortSwapInReceiver => Behaviors.stopped @@ -101,63 +123,128 @@ object SwapInReceiver { } } -private class SwapInReceiver(request: SwapInRequest, shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { +private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds private val keyManager: SwapKeyManager = nodeParams.swapKeyManager private val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) - private val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap receiver calculate an acceptable premium? - private val swapId: String = request.swapId - private val takerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey - private val takerPubkey: PublicKey = takerPrivkey.publicKey - private val makerPubkey: PublicKey = PublicKey(ByteVector.fromValidHex(request.pubkey)) + private val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat // TODO: how should swap receiver calculate an acceptable premium? + private val maxOpeningFee = (feeRatePerKw * openingTxWeight / 1000).toLong.sat // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? + private def takerPrivkey(swapId: String): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + private def takerPubkey(swapId: String): PublicKey = takerPrivkey(swapId).publicKey + private def makerPubkey(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): PublicKey = + PublicKey(ByteVector.fromValidHex( + if (isInitiator) { + agreement.pubkey + } else { + request.pubkey + })) + + private def createSwap(amount: Satoshi, swapId: String): Behavior[SwapCommand] = { + // a finalized scid must exist for the channel to create a swap + val request = SwapOutRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId.toString, amount.toLong, takerPubkey(swapId).toHex) + awaitAgreement(request) + } + + private def awaitAgreement(request: SwapOutRequest): Behavior[SwapCommand] = { + sendShortId(register, shortChannelId)(request) + + receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { + case SwapMessageReceived(agreement: SwapOutAgreement) if agreement.protocolVersion != protocolVersion => + swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) + case SwapMessageReceived(agreement: SwapOutAgreement) => validateFeeInvoice(request, agreement) + case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitAgreement")) + case ForwardFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap request to peer.")) + case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitAgreement", request) + Behaviors.same + } + } + + def validateFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement): Behavior[SwapCommand] = { + Bolt11Invoice.fromString(agreement.payreq) match { + case Success(i) if i.amount_opt.isDefined && i.amount_opt.get > maxOpeningFee => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Invoice amount ${i.amount_opt} > estimated opening tx fee $maxOpeningFee")) + case Success(i) if i.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Channel hop other than $shortChannelId found in invoice hints ${i.routingInfo}")) + case Success(i) if i.isExpired() => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Invoice is expired.")) + case Success(i) if i.amount_opt.isEmpty || i.amount_opt.get > maxOpeningFee => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: unacceptable opening fee requested.")) + case Success(feeInvoice) => payFeeInvoice(request, agreement, feeInvoice) + case Failure(e) => swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Could not parse payreq: $e")) + } + } + + def payFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement, feeInvoice: Bolt11Invoice): Behavior[SwapCommand] = { + watchForPayment(watch = true) // subscribe to payment event notifications + payInvoice(nodeParams)(paymentInitiator, request.swapId, feeInvoice) + + receiveSwapMessage[PayFeeInvoiceMessages](context, "payOpeningTxFeeInvoice") { + // TODO: add counter party to naughty list if they do not send openingTxBroadcasted and publish a valid opening tx after we pay the fee invoice + case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != feeInvoice.paymentHash => Behaviors.same + case PaymentEventReceived(_: PaymentSent) => Behaviors.same + case PaymentEventReceived(p: PaymentFailed) => swapCanceled(CreateFailed(request.swapId, s"Lightning payment failed: $p")) + case PaymentEventReceived(p: PaymentEvent) => swapCanceled(CreateFailed(request.swapId, s"Lightning payment failed, invalid PaymentEvent received: $p.")) + case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) + case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(m) => swapCanceled(CreateFailed(request.swapId, s"Invalid message received during payOpeningTxFeeInvoice: $m")) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during payFeeInvoice")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(CreateFailed(request.swapId, s"Cancel requested by user while validating opening tx.")) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) + Behaviors.same + } + } - private def validateRequest(): Behavior[SwapCommand] = { + def validateRequest(request: SwapInRequest): Behavior[SwapCommand] = { // fail if swap request is invalid, otherwise respond with agreement if (request.protocolVersion != protocolVersion || request.asset != noAsset || request.network != NodeParams.chainFromHash(nodeParams.chainHash)) { - swapCanceled(InternalError(swapId, s"swap $swapId incompatible request: $request.")) + swapCanceled(InternalError(request.swapId, s"incompatible request: $request.")) } else { - sendAgreement(SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium)) + sendAgreement(request, SwapInAgreement(protocolVersion, request.swapId, takerPubkey(request.swapId).toHex, premium.toLong)) } } - private def sendAgreement(agreement: SwapInAgreement): Behavior[SwapCommand] = { + private def sendAgreement(request: SwapInRequest, agreement: SwapInAgreement): Behavior[SwapCommand] = { // TODO: SHOULD fail any htlc that would change the channel into a state, where the swap invoice can not be payed until the swap invoice was payed. sendShortId(register, shortChannelId)(agreement) receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { - case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) if agreement.protocolVersion == request.protocolVersion && agreement.swapId == swapId => - awaitOpeningTxConfirmed(agreement, openingTxBroadcasted) - case SwapMessageReceived(cancel: CancelSwap) if cancel.swapId == swapId => swapCanceled(PeerCanceled(swapId)) - case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during sendAgreement: $m") - case StateTimeout => swapCanceled(InternalError(swapId, "timeout during sendAgreement")) - case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap agreement to peer.")) - case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) - sendCoopClose(s"Cancel requested by user after sending agreement.") - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "sendAgreement", ByteVector32.Zeroes, request, Some(agreement)) + case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = false) + case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during sendAgreement: $m") + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during sendAgreement")) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user after sending agreement.") + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) Behaviors.same } } - def awaitOpeningTxConfirmed(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + def awaitOpeningTxConfirmed(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) watchForTxConfirmation(watcher)(openingConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), 3) // watch for opening tx to be confirmed receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { - case OpeningTxConfirmed(opening) => validateOpeningTx(agreement, openingTxBroadcasted, opening.tx) - case SwapMessageReceived(cancel: CancelSwap) if cancel.swapId == swapId => swapCanceled(PeerCanceled(swapId)) - case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during awaitOpeningTxConfirmed: $m") - case InvoiceExpired => sendCoopClose("Timeout waiting for opening tx to confirm.") - case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) - sendCoopClose(s"Cancel requested by user while waiting for opening tx to confirm.") - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitOpeningTxConfirmed", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + case OpeningTxConfirmed(opening) => validateOpeningTx(request, agreement, openingTxBroadcasted, opening.tx, isInitiator) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during awaitOpeningTxConfirmed: $m") + case InvoiceExpired => sendCoopClose(request, "Timeout waiting for opening tx to confirm.") + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user while waiting for opening tx to confirm.") + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } - def validateOpeningTx(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction): Behavior[SwapCommand] = { + def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { case Success(i) if i.amount_opt.isDefined && i.amount_opt.get > request.amount.sat.toMilliSatoshi => context.self ! InvalidInvoice(s"Invoice amount ${i.amount_opt} > requested amount ${request.amount}") @@ -170,91 +257,93 @@ private class SwapInReceiver(request: SwapInRequest, shortChannelId: ShortChanne } receiveSwapMessage[ValidateTxMessages](context, "validateOpeningTx") { - case ValidInvoice(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash) => - payClaimInvoice(agreement, openingTxBroadcasted, invoice, openingTx) - case ValidInvoice(_) => sendCoopClose(s"Invalid opening tx: $openingTx", Some(openingTxBroadcasted)) - case InvalidInvoice(reason) => sendCoopClose(reason, Some(openingTxBroadcasted)) - case SwapMessageReceived(m) => sendCoopClose(s"Invalid message received during validateOpeningTx: $m", Some(openingTxBroadcasted)) - case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) - sendCoopClose(s"Cancel requested by user while validating opening tx.", Some(openingTxBroadcasted)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "validateOpeningTx", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + case ValidInvoice(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => + payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, openingTx, isInitiator) + case ValidInvoice(_) => sendCoopClose(request,s"Invalid opening tx: $openingTx", Some(openingTxBroadcasted)) + case InvalidInvoice(reason) => sendCoopClose(request, reason, Some(openingTxBroadcasted)) + case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during validateOpeningTx: $m", Some(openingTxBroadcasted)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user while validating opening tx.", Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "validateOpeningTx", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } - def payClaimInvoice(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, openingTx: Transaction): Behavior[SwapCommand] = { + def payClaimInvoice(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { watchForPayment(watch = true) // subscribe to payment event notifications - payInvoice(nodeParams)(paymentInitiator, swapId, invoice) + payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) receiveSwapMessage[PayClaimInvoiceMessages](context, "payClaimInvoice") { case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != invoice.paymentHash => Behaviors.same - case PaymentEventReceived(p: PaymentSent) => claimSwap(agreement, openingTxBroadcasted, invoice, p.paymentPreimage, openingTx) - case PaymentEventReceived(p: PaymentFailed) => sendCoopClose(s"Lightning payment failed: $p", Some(openingTxBroadcasted)) - case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(s"Lightning payment failed (invalid PaymentEvent received: $p).", Some(openingTxBroadcasted)) - case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) - sendCoopClose(s"Cancel requested by user while paying claim invoice.", Some(openingTxBroadcasted)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "payClaimInvoice", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + case PaymentEventReceived(p: PaymentSent) => claimSwap(request, agreement, openingTxBroadcasted, invoice, p.paymentPreimage, openingTx, isInitiator) + case PaymentEventReceived(p: PaymentFailed) => sendCoopClose(request, s"Lightning payment failed: $p", Some(openingTxBroadcasted)) + case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(request, s"Lightning payment failed (invalid PaymentEvent received: $p).", Some(openingTxBroadcasted)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user while paying claim invoice.", Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } - def claimSwap(agreement: SwapInAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, openingTx: Transaction): Behavior[SwapCommand] = { - val inputInfo = makeSwapOpeningInputInfo(openingTx.hash, openingTxBroadcasted.scriptOut.toInt, (request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash) - val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + def claimSwap(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { + val inputInfo = makeSwapOpeningInputInfo(openingTx.hash, openingTxBroadcasted.scriptOut.toInt, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPrivkey(request.swapId), paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) def claimByInvoiceConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) watchForPayment(watch = false) // unsubscribe from payment event notifications - commitClaim(wallet)(swapId, SwapClaimByCoopTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") + commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { case ClaimTxCommitted => Behaviors.same - case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByInvoiceConfirmed(swapId, confirmedTriggered)) + case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByInvoiceConfirmed(request.swapId, confirmedTriggered)) case SwapMessageReceived(m) => context.log.warn(s"received swap unhandled message while in state claimSwap: $m") Behaviors.same - case ClaimTxFailed(error) => context.log.error(s"swap $swapId claim by invoice tx failed, error: $error") + case ClaimTxFailed(error) => context.log.error(s"swap $request.swapId claim by invoice tx failed, error: $error") Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? - case ClaimTxInvalid(e) => context.log.error(s"swap $swapId claim by invoice tx is invalid: $e, tx: $claimByInvoiceTx") + case ClaimTxInvalid(e) => context.log.error(s"swap $request.swapId claim by invoice tx is invalid: $e, tx: $claimByInvoiceTx") Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? case StateTimeout => Behaviors.same // TODO: handle when claim tx not confirmed, retry or RBF the tx? can SwapInSender pin this tx with a low fee? - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after claim tx committed.") + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after claim tx committed.") Behaviors.same // ignore - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwap", ByteVector32.Zeroes, request, Some(agreement), None, Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } - def sendCoopClose(reason: String, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None): Behavior[SwapCommand] = { - context.log.error(s"swap $swapId sent coop close, reason: $reason") - sendShortId(register, shortChannelId)(CoopClose(swapId, reason, takerPrivkey.toHex)) + def sendCoopClose(request: SwapRequest, reason: String, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None): Behavior[SwapCommand] = { + context.log.error(s"swap ${request.swapId} sent coop close, reason: $reason") + sendShortId(register, shortChannelId)(CoopClose(request.swapId, reason, takerPrivkey(request.swapId).toHex)) def openingTxSpentAdapter: ActorRef[WatchOutputSpentTriggered] = context.messageAdapter[WatchOutputSpentTriggered](OpeningTxOutputSpent) openingTxBroadcasted_opt match { case Some(m) => watchForOutputSpent(watcher)(openingTxSpentAdapter, ByteVector32(ByteVector.fromValidHex(m.txId)), m.scriptOut.toInt) receiveSwapMessage[SendCoopCloseMessages](context, "sendCoopClose") { - case OpeningTxOutputSpent(_) => swapCompleted(ClaimByCoopOffered(swapId, reason)) - case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap coop close to peer.")) + case OpeningTxOutputSpent(_) => swapCompleted(ClaimByCoopOffered(request.swapId, reason)) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap coop close to peer.")) // TODO: set long enough timeout delay to wait for counterparty to sweep opening tx - case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) - swapCompleted(ClaimByCoopOffered(swapId, reason + "+ user canceled while waiting for opening tx to be swept by counter party.")) - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "sendCoopClose", ByteVector32.Zeroes, request, None, None, openingTxBroadcasted_opt) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCompleted(ClaimByCoopOffered(request.swapId, reason + "+ user canceled while waiting for opening tx to be swept by counter party.")) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "sendCoopClose", request, None, None, openingTxBroadcasted_opt) Behaviors.same } - case None => swapCompleted(ClaimByCoopOffered(swapId, reason)) + case None => swapCompleted(ClaimByCoopOffered(request.swapId, reason)) } } def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { context.system.eventStream ! Publish(event) - context.log.info(s"completed swap $swapId: $event.") + context.log.info(s"completed swap: $event.") Behaviors.stopped } def swapCanceled(failure: Fail): Behavior[SwapCommand] = { - context.system.eventStream ! Publish(Canceled(swapId)) + context.system.eventStream ! Publish(Canceled(failure.swapId)) failure match { case e: Error => context.log.error(s"canceled swap: $e") + case s: CreateFailed => sendShortId(register, shortChannelId)(CancelSwap(s.swapId, s.toString)) + context.log.info(s"canceled swap: $s") case s: Fail => context.log.info(s"canceled swap: $s") - case _ => context.log.error(s"canceled swap $swapId, reason: unknown.") + case _ => context.log.error(s"canceled swap ${failure.swapId}, reason: unknown.") } Behaviors.stopped } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala index eb52b1177b..f1e94f9cb0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala @@ -29,7 +29,6 @@ import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchTxConfirmedTriggered} import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.channel.{DATA_NORMAL, DATA_WAIT_FOR_FUNDING_CONFIRMED, RES_GET_CHANNEL_DATA} import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceivePayment} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.swap.SwapCommands._ @@ -37,18 +36,38 @@ import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapHelpers._ import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapInStatus, UserCanceled} import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta -import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByCoopTx, makeSwapClaimByCsvTx, makeSwapOpeningInputInfo} +import fr.acinq.eclair.swap.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{NodeParams, TimestampSecond} +import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond} import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Success} object SwapInSender { /* SwapInSender SwapInReceiver + RESPONDER INITIATOR + | | [createSwap] + | SwapOutRequest | + |<-------------------------------| + [validateRequest] | | [awaitAgreement] + | | + | SwapOutAgreement | + |------------------------------->| + [awaitFeePayment] | | [validateFeeInvoice] + | | + | | [payFeeInvoice] + |<------------------------------>| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + INITIATOR RESPONDER [createSwap] | | | SwapInRequest | @@ -86,122 +105,165 @@ object SwapInSender { def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet): Behavior[SwapCommands.SwapCommand] = Behaviors.setup { context => Behaviors.receiveMessagePartial { - case StartSwapInSender(amount, swapId, channelId) => - new SwapInSender(amount, swapId, channelId, nodeParams, watcher, register, wallet, context) - .createSwap() + case StartSwapInSender(amount, swapId, shortChannelId) => + new SwapInSender(shortChannelId, nodeParams, watcher, register, wallet, context) + .createSwap(amount, swapId) + case StartSwapOutReceiver(request: SwapOutRequest) => + ShortChannelId.fromCoordinates(request.scid) match { + case Success(shortChannelId) => new SwapInSender(shortChannelId, nodeParams, watcher, register, wallet, context) + .validateRequest(request) + case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") + Behaviors.stopped + } case RestoreSwapInSender(d) => - new SwapInSender(d.request.amount.sat, d.request.swapId, d.channelId, nodeParams, watcher, register, wallet, context) - .awaitClaimPayment(d.request, d.agreement, d.invoice, d.openingTxBroadcasted) + ShortChannelId.fromCoordinates(d.request.scid) match { + case Success(shortChannelId) => new SwapInSender(shortChannelId, nodeParams, watcher, register, wallet, context) + .awaitClaimPayment(d.request, d.agreement, d.invoice, d.openingTxBroadcasted, d.isInitiator) + case Failure(e) => context.log.error(s"could not restore swap sender with invalid shortChannelId: $d, $e") + Behaviors.stopped + } case AbortSwapInSender => Behaviors.stopped } } } -private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVector32, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { +private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds private val keyManager: SwapKeyManager = nodeParams.swapKeyManager private implicit val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + private val openingFee = (feeRatePerKw * openingTxWeight / 1000).toLong // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? private val maxPremium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap sender calculate an acceptable premium? - private def makerPrivkey(): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey - private def makerPubkey(): PublicKey = makerPrivkey().publicKey - private def takerPubkey(agreement: SwapInAgreement): PublicKey = PublicKey(ByteVector.fromValidHex(agreement.pubkey)) - - private def createSwap(): Behavior[SwapCommand] = { - // a finalized scid must exist for the channel to create a swap - queryChannelData(register, channelId) - receiveSwapMessage[CreateSwapMessages](context, "createSwap") { - case ChannelDataFailure(e) => swapCanceled(InternalError(swapId, s"channel data query failure: ${e.fwd}.")) - case ChannelDataResult(RES_GET_CHANNEL_DATA(channelData)) if channelData.isInstanceOf[DATA_NORMAL] => - val shortChannelId = channelData.asInstanceOf[DATA_NORMAL].shortIds.real.toOption.get.toString - awaitAgreement(SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId, amount.toLong, makerPubkey().toHex)) - case ChannelDataResult(RES_GET_CHANNEL_DATA(channelData)) if channelData.isInstanceOf[DATA_WAIT_FOR_FUNDING_CONFIRMED] => - swapCanceled(CreateFailed(swapId, "Channel waiting for funding to be confirmed.")) - case ChannelDataResult(channelData) => swapCanceled(CreateFailed(swapId, s"invalid channel: $channelData.")) - case StateTimeout => swapCanceled(CreateFailed(swapId, "timeout during createSwap")) + private def makerPrivkey(swapId: String): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + private def makerPubkey(swapId: String): PublicKey = makerPrivkey(swapId).publicKey + + private def takerPubkey(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): PublicKey = + PublicKey(ByteVector.fromValidHex( + if (isInitiator) { + agreement.pubkey + } else { + request.pubkey + })) + + private def createSwap(amount: Satoshi, swapId: String): Behavior[SwapCommand] = { + awaitAgreement(SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId.toString, amount.toLong, makerPubkey(swapId).toHex)) + } + + def validateRequest(request: SwapOutRequest): Behavior[SwapCommand] = { + // fail if swap out request is invalid, otherwise respond with agreement + if (request.protocolVersion != protocolVersion || request.asset != noAsset || request.network != NodeParams.chainFromHash(nodeParams.chainHash)) { + swapCanceled(InternalError(request.swapId, s"incompatible request: $request.")) + } else { + createInvoice(nodeParams, openingFee.sat, "receive-swap-out") match { + case Success(invoice) => awaitFeePayment(request, SwapOutAgreement(protocolVersion, request.swapId, makerPubkey(request.swapId).toHex, invoice.toString), invoice) + case Failure(exception) => swapCanceled(CreateFailed(request.swapId, "could not create invoice")) + } + } + } + + private def awaitFeePayment(request: SwapOutRequest, agreement: SwapOutAgreement, invoice: Bolt11Invoice): Behavior[SwapCommand] = { + watchForPayment(watch = true) // subscribe to be notified of payment events + sendShortId(register, shortChannelId)(agreement) + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapFeeExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[AwaitFeePaymentMessages](context, "sendAgreement") { + case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= invoice.amount_opt.get => + createOpeningTx(request, agreement, isInitiator = false) + case PaymentEventReceived(_) => Behaviors.same + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitFeePayment", m)) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitFeePayment")) + case InvoiceExpired => swapCanceled(InternalError(request.swapId, "fee payment invoice expired")) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) + Behaviors.same + } } } private def awaitAgreement(request: SwapInRequest): Behavior[SwapCommand] = { - send(register, channelId)(request) + sendShortId(register, shortChannelId)(request) receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { case SwapMessageReceived(agreement: SwapInAgreement) if agreement.protocolVersion != protocolVersion => - swapCanceled(InternalError(swapId, s"protocol version must be $protocolVersion.")) + swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) case SwapMessageReceived(agreement: SwapInAgreement) if agreement.premium > maxPremium => - swapCanceled(InternalError(swapId, "unacceptable premium requested.")) - case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement) - case SwapMessageReceived(cancel: CancelSwap) if cancel.swapId == swapId => swapCanceled(PeerCanceled(swapId)) - case StateTimeout => swapCanceled(InternalError(swapId, "timeout during awaitAgreement")) - case ForwardFailureAdapter(_) => swapCanceled(InternalError(swapId, s"could not forward swap request to peer.")) - case SwapMessageReceived(m) => swapCanceled(InvalidMessage(swapId, "awaitAgreement", m)) - case CancelRequested(replyTo) => replyTo ! UserCanceled(swapId) - swapCanceled(UserCanceled(swapId)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitAgreement", channelId, request) + swapCanceled(InternalError(request.swapId, "unacceptable premium requested.")) + case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement, isInitiator = true) + case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitAgreement")) + case ForwardFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap request to peer.")) + case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitAgreement", request) Behaviors.same } } - def createOpeningTx(request: SwapInRequest, agreement: SwapInAgreement): Behavior[SwapCommand] = { + def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = { val receivePayment = ReceivePayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left("send-swap-in")) val createInvoice = context.spawnAnonymous(CreateInvoiceActor(nodeParams)) createInvoice ! CreateInvoiceActor.CreateInvoice(context.messageAdapter[Bolt11Invoice](InvoiceResponse).toClassic, receivePayment) receiveSwapMessage[CreateOpeningTxMessages](context, "createOpeningTx") { - case InvoiceResponse(invoice: Bolt11Invoice) => fundOpening(wallet, feeRatePerKw)(request, agreement, invoice) + case InvoiceResponse(invoice: Bolt11Invoice) => fundOpening(wallet, feeRatePerKw)((request.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) Behaviors.same // TODO: checkpoint PersistentSwapData for this swap to a database before committing the opening tx - case OpeningTxFunded(invoice, fundingResponse) => commitOpening(wallet)(swapId, invoice, fundingResponse, "swap-in-sender-opening") + case OpeningTxFunded(invoice, fundingResponse) => commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") Behaviors.same case OpeningTxCommitted(invoice, openingTxBroadcasted) => - awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted) - case OpeningTxFailed(error, None) => swapCanceled(InternalError(swapId, s"failed to fund swap open tx, error: $error")) + awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case OpeningTxFailed(error, None) => swapCanceled(InternalError(request.swapId, s"failed to fund swap open tx, error: $error")) case OpeningTxFailed(error, Some(r)) => rollback(wallet)(error, r.fundingTx) Behaviors.same - case RollbackSuccess(error, value) => swapCanceled(InternalError(swapId, s"rollback: Success($value), error: $error")) - case RollbackFailure(error, t) => swapCanceled(InternalError(swapId, s"rollback exception: $t, error: $error")) + case RollbackSuccess(error, value) => swapCanceled(InternalError(request.swapId, s"rollback: Success($value), error: $error")) + case RollbackFailure(error, t) => swapCanceled(InternalError(request.swapId, s"rollback exception: $t, error: $error")) case SwapMessageReceived(_) => Behaviors.same // ignore case StateTimeout => // TODO: are we sure the opening transaction has not yet been committed? should we rollback locked funding outputs? - swapCanceled(InternalError(swapId, "timeout during CreateOpeningTx")) - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + swapCanceled(InternalError(request.swapId, "timeout during CreateOpeningTx")) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same // ignore - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "createOpeningTx", channelId, request, Some(agreement)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) Behaviors.same } } - def awaitClaimPayment(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + def awaitClaimPayment(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { // TODO: query payment database for received payment watchForPayment(watch = true) // subscribe to be notified of payment events - send(register, channelId)(openingTxBroadcasted) // send message to peer about opening tx broadcast + sendShortId(register, shortChannelId)(openingTxBroadcasted) // send message to peer about opening tx broadcast Behaviors.withTimers { timers => - timers.startSingleTimer(swapInvoiceExpiredTimer(swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { - case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= request.amount.sat && payment.parts.forall(p => p.fromChannelId == channelId) => - swapCompleted(ClaimByInvoicePaid(swapId, payment)) - case SwapMessageReceived(coopClose: CoopClose) if coopClose.swapId == swapId => - claimSwapCoop(request, agreement, invoice, openingTxBroadcasted, coopClose) + // TODO: do we need to check that all payment parts were on our given channel? eg. payment.parts.forall(p => p.fromChannelId == channelId) + case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= request.amount.sat => + swapCompleted(ClaimByInvoicePaid(request.swapId, payment)) + case SwapMessageReceived(coopClose: CoopClose) => claimSwapCoop(request, agreement, invoice, openingTxBroadcasted, coopClose, isInitiator) case PaymentEventReceived(_) => Behaviors.same case SwapMessageReceived(_) => Behaviors.same case InvoiceExpired => - waitCsv(request, agreement, invoice, openingTxBroadcasted) - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "awaitClaimPayment", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } } - def claimSwapCoop(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, coopClose: CoopClose): Behavior[SwapCommand] = { + def claimSwapCoop(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, coopClose: CoopClose, isInitiator: Boolean): Behavior[SwapCommand] = { val takerPrivkey = PrivateKey(ByteVector.fromValidHex(coopClose.privkey)) val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) - val claimByCoopTx = makeSwapClaimByCoopTx(request.amount.sat + agreement.premium.sat, makerPrivkey(), takerPrivkey, invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) - val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(), takerPrivkey.publicKey, invoice.paymentHash) + val claimByCoopTx = makeSwapClaimByCoopTx(request.amount.sat + agreement.premium.sat, makerPrivkey(request.swapId), takerPrivkey, invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(request.swapId), takerPrivkey.publicKey, invoice.paymentHash) def claimByCoopConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) @@ -210,21 +272,21 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { case OpeningTxConfirmed(_) => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) - commitClaim(wallet)(swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") + commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") Behaviors.same case ClaimTxCommitted => Behaviors.same case ClaimTxConfirmed(confirmedTriggered) => - swapCompleted(ClaimByCoopConfirmed(swapId, confirmedTriggered)) - case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) - case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + swapCompleted(ClaimByCoopConfirmed(request.swapId, confirmedTriggered)) + case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwapCoop", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } - def waitCsv(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + def waitCsv(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { // TODO: are we sure the opening transaction has been committed? should we rollback locked funding outputs? def csvDelayConfirmedAdapter: ActorRef[WatchFundingDeeplyBuriedTriggered] = context.messageAdapter[WatchFundingDeeplyBuriedTriggered](CsvDelayConfirmed) watchForPayment(watch = false) @@ -232,37 +294,37 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto receiveSwapMessage[WaitCsvMessages](context, "waitCsv") { case CsvDelayConfirmed(_) => - claimSwapCsv(request, agreement, invoice, openingTxBroadcasted) + claimSwapCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) case StateTimeout => // TODO: problem with the blockchain monitor? Behaviors.same - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "waitCsv", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } - def claimSwapCsv(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted): Behavior[SwapCommand] = { + def claimSwapCsv(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) - val claimByCsvTx = makeSwapClaimByCsvTx(request.amount.sat + agreement.premium.sat, makerPrivkey(), takerPubkey(agreement), invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) - val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(), takerPubkey(agreement), invoice.paymentHash) + val claimByCsvTx = makeSwapClaimByCsvTx(request.amount.sat + agreement.premium.sat, makerPrivkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice.paymentHash) def claimByCsvConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) - commitClaim(wallet)(swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "swap-in-sender-claimByCsvTx") + commitClaim(wallet)(request.swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "swap-in-sender-claimByCsvTx") receiveSwapMessage[ClaimSwapCsvMessages](context, "claimSwapCsv") { case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCsvConfirmedAdapter, claimByCsvTx.txid, nodeParams.channelConf.minDepthBlocks) Behaviors.same - case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByCsvConfirmed(swapId, confirmedTriggered)) - case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) - case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted) + case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByCsvConfirmed(request.swapId, confirmedTriggered)) + case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) case StateTimeout => // TODO: handle when claim tx not confirmed, resubmit the tx? Behaviors.same - case CancelRequested(replyTo) => replyTo ! SwapError(swapId, "Can not cancel swap after opening tx committed.") + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(swapId, context.self.toString, "claimSwapCsv", channelId, request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } @@ -274,12 +336,12 @@ private class SwapInSender(amount: Satoshi, swapId: String, channelId: ByteVecto } def swapCanceled(failure: Fail): Behavior[SwapCommand] = { - context.system.eventStream ! Publish(Canceled(swapId)) - if (!failure.isInstanceOf[PeerCanceled]) send(register, channelId)(CancelSwap(swapId, failure.toString)) + context.system.eventStream ! Publish(Canceled(failure.swapId)) + if (!failure.isInstanceOf[PeerCanceled]) sendShortId(register, shortChannelId)(CancelSwap(failure.swapId, failure.toString)) failure match { case e: Error => context.log.error(s"canceled swap: $e") case f: Fail => context.log.info(s"canceled swap: $f") - case _ => context.log.error(s"canceled swap $swapId, reason: unknown.") + case _ => context.log.error(s"canceled swap $failure.swapId, reason: unknown.") } Behaviors.stopped } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala index 2be5c4cc68..f02f6ef3aa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala @@ -26,11 +26,11 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapData.SwapInSenderData +import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapRegister.Command import fr.acinq.eclair.swap.SwapResponses.{Response, Status, SwapOpened} -import fr.acinq.eclair.wire.protocol.{HasSwapId, SwapInRequest} -import fr.acinq.eclair.{NodeParams, randomBytes32} +import fr.acinq.eclair.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest} +import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt @@ -45,26 +45,24 @@ object SwapRegister { } sealed trait RegisteringMessages extends Command - case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, channelId: ByteVector32) extends RegisteringMessages with ReplyToMessages + case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages + case class SwapOutRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages case class MessageReceived(message: HasSwapId) extends RegisteringMessages - case class SwapTerminated(swapInSenderId: SwapInSenderId) extends RegisteringMessages + case class SwapTerminated(swapInSenderId: SwapId) extends RegisteringMessages case class ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) extends RegisteringMessages case class CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) extends RegisteringMessages with ReplyToMessages - sealed trait SwapId { - def id: String - } - case class SwapInSenderId(id: String) extends SwapId { + case class SwapId(id: String) { def toByteVector32: ByteVector32 = ByteVector32(ByteVector.fromValidHex(id)) } // @formatter:on - def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapInSenderData] = Set()): Behavior[Command] = Behaviors.setup { context => + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData] = Set()): Behavior[Command] = Behaviors.setup { context => new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, wallet, data).initializing } } -private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapInSenderData] = Set()) { +private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData] = Set()) { import SwapRegister._ private def myReceive[B <: Command : ClassTag](stateName: String)(f: B => Behavior[Command]): Behavior[Command] = @@ -81,41 +79,57 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam // TODO: restore 'data' from database val swaps = data.map { state => val swap: typed.ActorRef[SwapCommands.SwapCommand] = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) - .onFailure(typed.SupervisorStrategy.restart), "SwapInSender-"+state.channelId.toHex) - context.watchWith(swap, SwapTerminated(SwapInSenderId(state.request.swapId))) + .onFailure(typed.SupervisorStrategy.restart), "SwapInSender-"+state.request.scid) + context.watchWith(swap, SwapTerminated(SwapId(state.request.swapId))) swap ! RestoreSwapInSender(state) - SwapInSenderId(state.request.swapId) -> swap.unsafeUpcast + SwapId(state.request.swapId) -> swap.unsafeUpcast }.toMap registering(swaps) } - private def registering(swaps: Map[SwapInSenderId, ActorRef[Any]]): Behavior[Command] = { + private def registering(swaps: Map[SwapId, ActorRef[Any]]): Behavior[Command] = { // TODO: fail requests for swaps on a channel if one already exists for the channel; keep a list of channels with active swaps myReceive[RegisteringMessages]("registering") { - case SwapInRequested(replyTo, amount, channelId) => + case SwapInRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex val swap = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) - .onFailure(SupervisorStrategy.restart), "SwapInSender-"+channelId.toHex) - context.watchWith(swap, SwapTerminated(SwapInSenderId(swapId))) - swap ! StartSwapInSender(amount, swapId, channelId) + .onFailure(SupervisorStrategy.restart), "Swap-"+shortChannelId.toHex) + context.watchWith(swap, SwapTerminated(SwapId(swapId))) + swap ! StartSwapInSender(amount, swapId, shortChannelId) + replyTo ! SwapOpened(swapId) + registering(swaps + (SwapId(swapId) -> swap.unsafeUpcast)) + + case SwapOutRequested(replyTo, amount, channelId) => + val swapId = randomBytes32().toHex + val swap = context.spawn(Behaviors.supervise(SwapInReceiver(nodeParams, paymentInitiator, watcher, register, wallet)) + .onFailure(SupervisorStrategy.restart), "Swap-" + channelId.toHex) + context.watchWith(swap, SwapTerminated(SwapId(swapId))) + swap ! StartSwapOutSender(amount, swapId, channelId) replyTo ! SwapOpened(swapId) - registering(swaps + (SwapInSenderId(swapId) -> swap.unsafeUpcast)) + registering(swaps + (SwapId(swapId) -> swap.unsafeUpcast)) case MessageReceived(request: SwapInRequest) => - val swap = context.spawn(Behaviors.supervise(SwapInReceiver(request, nodeParams, paymentInitiator, watcher, register, wallet)) - .onFailure(SupervisorStrategy.restart), "SwapInReceiver-"+request.scid) - context.watchWith(swap, SwapTerminated(SwapInSenderId(request.swapId))) - swap ! StartSwapInReceiver - registering(swaps + (SwapInSenderId(request.swapId) -> swap.unsafeUpcast)) + val swap = context.spawn(Behaviors.supervise(SwapInReceiver(nodeParams, paymentInitiator, watcher, register, wallet)) + .onFailure(SupervisorStrategy.restart), "Swap-"+request.scid) + context.watchWith(swap, SwapTerminated(SwapId(request.swapId))) + swap ! StartSwapInReceiver(request) + registering(swaps + (SwapId(request.swapId) -> swap.unsafeUpcast)) + + case MessageReceived(request: SwapOutRequest) => + val swap = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) + .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) + context.watchWith(swap, SwapTerminated(SwapId(request.swapId))) + swap ! StartSwapOutReceiver(request) + registering(swaps + (SwapId(request.swapId) -> swap.unsafeUpcast)) - case MessageReceived(msg) => swaps.get(SwapInSenderId(msg.swapId)) match { + case MessageReceived(msg) => swaps.get(SwapId(msg.swapId)) match { case Some(swap) => swap ! SwapMessageReceived(msg) Behaviors.same case None => context.log.error(s"received unhandled message for swap ${msg.swapId}: $msg") Behaviors.same } - case SwapTerminated(swapInSenderId) => registering(swaps - SwapInSenderId(swapInSenderId.id)) + case SwapTerminated(swapInSenderId) => registering(swaps - SwapId(swapInSenderId.id)) case ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) => // TODO: is this the best way to do this?! @@ -124,7 +138,7 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam Behaviors.same case CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) => - swaps.get(SwapInSenderId(swapId)) match { + swaps.get(SwapId(swapId)) match { case Some(swap) => swap ! CancelRequested(replyTo) Behaviors.same case None => context.log.error(s"could not cancel swap $swapId: does not exist") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala index 98c47e9f82..8529455a72 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala @@ -16,9 +16,8 @@ package fr.acinq.eclair.swap -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} import fr.acinq.eclair.payment.Bolt11Invoice -import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapAgreement, SwapRequest} object SwapResponses { @@ -60,25 +59,14 @@ object SwapResponses { override def toString: String = s"swap $swapId swap error: $reason." } - case class InsufficientBalanceForReceive(swapId: String, amount: Satoshi, availableForReceive: Satoshi) extends Error { - override def toString: String = s"swap $swapId error: requested amount of $amount sat > available channel balance to receive of $availableForReceive sat." - } - - case class InsufficientBalanceForSend(swapId: String, amount: Satoshi, availableForSend: Satoshi) extends Error { - override def toString: String = s"swap $swapId error: requested amount of $amount sat > available channel balance to send of $availableForSend sat." - } - - case class InsufficientOnChainBalance(swapId: String, amount: Satoshi, maxPremium: Satoshi, onChainBalance: Satoshi) extends Error { - override def toString: String = s"swap $swapId error: requested amount of $amount + $maxPremium maximum premium > confirmed on-chain balance of $onChainBalance." - } - case class InternalError(swapId: String, reason: String) extends Error { override def toString: String = s"swap $swapId internal error: $reason." } sealed trait Status extends Response - case class SwapInStatus(swapId: String, actor: String, behavior: String, channelId: ByteVector32, request: SwapInRequest, agreement_opt: Option[SwapInAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None) extends Status { - override def toString: String = s"$actor[$behavior]: $swapId, $channelId, $request, $agreement_opt, $invoice_opt, $openingTxBroadcasted_opt" + + case class SwapInStatus(swapId: String, actor: String, behavior: String, request: SwapRequest, agreement_opt: Option[SwapAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None) extends Status { + override def toString: String = s"$actor[$behavior]: $swapId, ${request.scid}, $request, $agreement_opt, $invoice_opt, $openingTxBroadcasted_opt" } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala index 6d13c6b20f..49e1d401e7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala @@ -39,7 +39,8 @@ object SwapTransactions { val PlaceHolderSig: ByteVector64 = ByteVector64(ByteVector.fill(64)(0xaa)) assert(der(PlaceHolderSig).size == 72) - val claimByInvoiceTxWeight = 593 // TODO: add test to confirm this is the actual weight of claimByInvoice tx + val claimByInvoiceTxWeight = 593 // TODO: add test to confirm this is the actual weight of the claimByInvoice tx in vBytes + val openingTxWeight = 610 // TODO: compute and add test to confirm this is the actual weight of the opening tx in vBytes def makeSwapOpeningInputInfo(fundingTxId: ByteVector32, fundingTxOutputIndex: Int, amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): InputInfo = { val redeemScript = swapOpening(makerPubkey, takerPubkey, paymentHash) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 3689a16609..7e8b7ca352 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -500,13 +500,33 @@ sealed abstract class JSonBlobMessage() extends PeerSwapMessage { } } -case class SwapInRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends JSonBlobMessage with HasSwapId +sealed trait HasSwapVersion { def protocolVersion: Long} -case class SwapOutRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends JSonBlobMessage with HasSwapId +sealed trait SwapRequest extends JSonBlobMessage with HasSwapId with HasSwapVersion { + def asset: String + def network: String + def scid: String + def amount: Long + def pubkey: String +} + +case class SwapInRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest -case class SwapInAgreement(protocolVersion: Long, swapId: String, pubkey: String, premium: Long) extends JSonBlobMessage with HasSwapId +case class SwapOutRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest + +sealed trait SwapAgreement extends JSonBlobMessage with HasSwapId with HasSwapVersion { + def pubkey: String + def premium: Long + def payreq: String +} -case class SwapOutAgreement(protocolVersion: Long, swapId: String, pubkey: String, payreq: String) extends JSonBlobMessage with HasSwapId +case class SwapInAgreement(protocolVersion: Long, swapId: String, pubkey: String, premium: Long) extends SwapAgreement { + override def payreq: String = "" +} + +case class SwapOutAgreement(protocolVersion: Long, swapId: String, pubkey: String, payreq: String) extends SwapAgreement { + override def premium: Long = 0 +} case class OpeningTxBroadcasted(swapId: String, payreq: String, txId: String, scriptOut: Long, blindingKey: String) extends JSonBlobMessage with HasSwapId diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala index f08ebb9c58..e9f7759584 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala @@ -34,7 +34,7 @@ import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapData.SwapInReceiverData +import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} @@ -92,24 +92,24 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInReceiver(request, TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInReceiver(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } case class FixtureParam(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) - test("happy path from restored swap") { f => + test("happy path from restored swap in") { f => import f._ // restore the SwapInReceiver actor state from a confirmed on-chain opening tx val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) - val swapData = SwapInReceiverData(request, agreement, invoice, openingTxBroadcasted) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = false) swapInReceiver ! RestoreSwapInReceiver(swapData) monitor.expectMessageType[RestoreSwapInReceiver] - // SwapInReceiver reports status of awaiting payment + // SwapInReceiver reports status of awaiting opening transaction swapInReceiver ! GetStatus(userCli.ref) monitor.expectMessageType[GetStatus] assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitOpeningTxConfirmed") @@ -148,14 +148,14 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. deathWatcher.expectTerminated(swapInReceiver) } - test("happy path for new swap") { f => + test("happy path for new swap in") { f => import f._ - // start new SwapInSender - swapInReceiver ! StartSwapInReceiver - monitor.expectMessage(StartSwapInReceiver) + // start new SwapInReceiver + swapInReceiver ! StartSwapInReceiver(request) + monitor.expectMessage(StartSwapInReceiver(request)) - // Taker:SwapInAgreement -> Maker + // SwapInReceiver:SwapInAgreement -> SwapInSender val agreement = register.expectMessageType[ForwardShortId[SwapInAgreement]].message // Maker:OpeningTxBroadcasted -> Taker diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala index 486071da14..e756fbcee3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala @@ -29,11 +29,11 @@ import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} -import fr.acinq.eclair.channel.Register.Forward -import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, DATA_NORMAL, RES_GET_CHANNEL_DATA} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapData.SwapInSenderData +import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec @@ -57,7 +57,8 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId - val makerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val keyManager: SwapKeyManager = TestConstants.Alice.nodeParams.swapKeyManager + val makerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey val takerPrivkey: PrivateKey = PrivateKey(randomBytes32()) val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey val makerPubkey: PublicKey = makerPrivkey.publicKey @@ -101,11 +102,11 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // restore the SwapInSender actor state from a confirmed on-chain opening tx val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) - val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) swapInSender ! RestoreSwapInSender(swapData) // resend OpeningTxBroadcasted when swap restored - register.expectMessageType[Forward[OpeningTxBroadcasted]] + register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -132,14 +133,10 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo import f._ // start new SwapInSender - swapInSender ! StartSwapInSender(amount, swapId, channelId) - - // SwapInSender will first request channel data to get shortChannelId - val getChannelData = register.expectMessageType[Forward[CMD_GET_CHANNEL_DATA]] - getChannelData.replyTo.toClassic ! RES_GET_CHANNEL_DATA(channelData) + swapInSender ! StartSwapInSender(amount, swapId, shortChannelId) // SwapInSender: SwapInRequest -> SwapInSender - val swapInRequest = register.expectMessageType[Forward[SwapInRequest]] + val swapInRequest = register.expectMessageType[ForwardShortId[SwapInRequest]] // SwapInReceiver: SwapInAgreement -> SwapInSender swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.message.protocolVersion, swapInRequest.message.swapId, takerPubkey.toString(), premium)) @@ -148,7 +145,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val openingTx = swapEvents.expectMessageType[TransactionPublished].tx // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver - val openingTxBroadcasted = register.expectMessageType[Forward[OpeningTxBroadcasted]] + val openingTxBroadcasted = register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] val invoice = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get // wait for SwapInSender to subscribe to PaymentEventReceived messages @@ -176,11 +173,11 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // restore the SwapInSender actor state from a confirmed on-chain opening tx val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) - val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) swapInSender ! RestoreSwapInSender(swapData) // resend OpeningTxBroadcasted when swap restored - register.expectMessageType[Forward[OpeningTxBroadcasted]] + register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -213,11 +210,11 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice with short expiry"), CltvExpiryDelta(18), expirySeconds = Some(2)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) - val swapData = SwapInSenderData(channelId, request, agreement, invoice, openingTxBroadcasted) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) swapInSender ! RestoreSwapInSender(swapData) // resend OpeningTxBroadcasted when swap restored - register.expectMessageType[Forward[OpeningTxBroadcasted]] + register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] // wait to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala index a188d34fcb..4c461a7bae 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala @@ -4,7 +4,6 @@ import akka.actor.typed.scaladsl.adapter._ import akka.actor.{ActorSystem, Kill} import akka.testkit.TestProbe import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} -import fr.acinq.eclair.BlockHeight import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} @@ -12,11 +11,12 @@ import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture import fr.acinq.eclair.integration.basic.fixtures.composite.TwoNodesFixture import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentSent} import fr.acinq.eclair.swap.SwapEvents._ -import fr.acinq.eclair.swap.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapInRequested} +import fr.acinq.eclair.swap.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapInRequested, SwapOutRequested} import fr.acinq.eclair.swap.SwapResponses.{Status, SwapOpened} import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta -import fr.acinq.eclair.swap.SwapTransactions.claimByInvoiceTxWeight +import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, openingTxWeight} import fr.acinq.eclair.testutils.FixtureSpec +import fr.acinq.eclair.{BlockHeight, ShortChannelId} import org.scalatest.TestData import org.scalatest.concurrent.{IntegrationPatience, PatienceConfiguration} import scodec.bits.HexStringSyntax @@ -58,11 +58,12 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { (aliceSwap, bobSwap) } - def connectNodes(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): ByteVector32 = { + def connectNodes(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): ShortChannelId = { connect(alice, bob)(system) val channelId = openChannel(alice, bob, 100_000 sat)(system).channelId confirmChannel(alice, bob, channelId, BlockHeight(420_000), 21)(system) confirmChannelDeep(alice, bob, channelId, BlockHeight(420_000), 21)(system) + val shortChannelId = getChannelData(alice, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.toOption.get assert(getChannelData(alice, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) assert(getChannelData(bob, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) @@ -72,14 +73,14 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { alice.watcher.expectMsgType[WatchExternalChannelSpent] bob.watcher.expectMsgType[WatchExternalChannelSpent] - channelId + shortChannelId } test("swap in - claim by invoice") { f => import f._ val (aliceSwap, bobSwap) = swapProbes(alice, bob) - val channelId = connectNodes(alice, bob) + val shortChannelId = connectNodes(alice, bob) // bob must have enough on-chain balance to send val amount = Satoshi(1000) @@ -90,7 +91,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -124,7 +125,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { import f._ val (aliceSwap, bobSwap) = swapProbes(alice, bob) - val channelId = connectNodes(alice, bob) + val shortChannelId = connectNodes(alice, bob) // swap more satoshis than alice has available in the channel to send to bob val amount = 100_000 sat @@ -135,7 +136,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -179,7 +180,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { import f._ val (_, bobSwap) = swapProbes(alice, bob) - val channelId = connectNodes(alice, bob) + val shortChannelId = connectNodes(alice, bob) // bob must have enough on-chain balance to send val amount = Satoshi(1000) @@ -190,7 +191,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -221,7 +222,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { import f._ val (aliceSwap, bobSwap) = swapProbes(alice, bob) - val channelId = connectNodes(alice, bob) + val shortChannelId = connectNodes(alice, bob) // bob must have enough on-chain balance to send val amount = Satoshi(1000) @@ -232,7 +233,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, channelId) + bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx is published, but NOT yet confirmed on-chain @@ -254,4 +255,53 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(bobSwap.swapEvents.expectMsgType[ClaimByCoopConfirmed].swapId == swapId) } + test("swap out - claim by invoice") { f => + import f._ + + val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val shortChannelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val fee = (feeRatePerKw * openingTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByInvoiceBlock = BlockHeight(4) + bob.wallet.confirmedBalance = amount + fee + + // swap out receiver (alice) requests a swap out with swap out sender (bob) + alice.swapRegister ! SwapOutRequested(aliceSwap.cli.ref, amount, shortChannelId) + val swapId = aliceSwap.cli.expectMsgType[SwapOpened].swapId + + // swap out receiver (alice) sends a payment of `fee` to swap out sender (bob) + assert(aliceSwap.paymentEvents.expectMsgType[PaymentSent].recipientAmount === toMilliSatoshi(fee)) + assert(bobSwap.paymentEvents.expectMsgType[PaymentReceived].amount === toMilliSatoshi(fee)) + + // swap out sender (bob) confirms opening tx published + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount) + + // bob has status of 1 pending swap + bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // swap out receiver (alice) confirms opening tx on-chain + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + assert(openingTx.txOut.head.amount == amount) + + // swap out receiver (alice) sends a payment of `amount` to swap out sender (bob) + assert(aliceSwap.paymentEvents.expectMsgType[PaymentSent].recipientAmount === toMilliSatoshi(amount)) + assert(bobSwap.paymentEvents.expectMsgType[PaymentReceived].amount === toMilliSatoshi(amount)) + + // swap out receiver (alice) confirms claim-by-invoice tx published + val claimTx = aliceSwap.swapEvents.expectMsgType[TransactionPublished].tx + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByInvoiceBlock, 0, claimTx) + + // both parties publish that the swap was completed via claim-by-invoice + assert(aliceSwap.swapEvents.expectMsgType[ClaimByInvoiceConfirmed].swapId == swapId) + assert(bobSwap.swapEvents.expectMsgType[ClaimByInvoicePaid].swapId == swapId) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala new file mode 100644 index 0000000000..cd0dde6106 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala @@ -0,0 +1,141 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.swap.SwapTransactions.openingTxWeight +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} +import fr.acinq.eclair.{NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import scala.concurrent.duration._ + +// with BitcoindService +case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val openingFee: Long = (feeRatePerKw * openingTxWeight / 1000).toLong // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val keyManager: SwapKeyManager = TestConstants.Alice.nodeParams.swapKeyManager + val makerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + val takerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val makerPubkey: PublicKey = makerPrivkey.publicKey + val takerPubkey: PublicKey = takerPrivkey.publicKey + val paymentPreimage: ByteVector32 = ByteVector32.One + val feePreimage: ByteVector32 = ByteVector32.Zeroes + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapOutRequest = SwapOutRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, takerPubkey.toHex) + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + + val wallet = new DummyOnChainWallet() + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + + // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInSender(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + + withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path for new swap out") { f => + import f._ + + // start new SwapInSender + swapInSender ! StartSwapOutReceiver(request) + monitor.expectMessage(StartSwapOutReceiver(request)) + + // SwapInSender:SwapOutAgreement -> SwapInReceiver + val agreement = register.expectMessageType[ForwardShortId[SwapOutAgreement]].message + assert(agreement.pubkey == makerPubkey.toHex) + + // SwapInReceiver pays the fee invoice + val feeInvoice = Bolt11Invoice.fromString(agreement.payreq).get + val feeReceived = PaymentReceived(feeInvoice.paymentHash, Seq(PaymentReceived.PartialPayment(openingFee.sat.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + swapEvents.expectNoMessage() + testKit.system.eventStream ! Publish(feeReceived) + + // SwapInSender publishes opening tx on-chain + val openingTx = swapEvents.expectMessageType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount) + + // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver + val openingTxBroadcasted = register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] + val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInSender reports status of awaiting payment + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitClaimPayment") + + // SwapInSender receives a payment with the corresponding payment hash + // TODO: convert from ShortChannelId to ByteVector32 + val paymentReceived = PaymentReceived(paymentInvoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByInvoicePaid] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala new file mode 100644 index 0000000000..fb98599e7b --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala @@ -0,0 +1,176 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.swap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} +import fr.acinq.eclair.swap.SwapCommands._ +import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.swap.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import java.util.UUID +import scala.concurrent.duration._ + +// with BitcoindService +case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Bob.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val fee: Satoshi = 100 sat + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val keyManager: SwapKeyManager = TestConstants.Bob.nodeParams.swapKeyManager + val makerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val takerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val makerPubkey: PublicKey = makerPrivkey.publicKey + val takerPubkey: PublicKey = takerPrivkey.publicKey + val feeRatePerKw: FeeratePerKw = TestConstants.Bob.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Bob.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val paymentPreimage: ByteVector32 = ByteVector32.One + val feePreimage: ByteVector32 = ByteVector32.Zeroes + val paymentInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapInReceiver payment invoice"), CltvExpiryDelta(18)) + val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(feePreimage), makerPrivkey, Left("SwapOutReceiver fee invoice"), CltvExpiryDelta(18)) + val otherInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), randomBytes32(), makerPrivkey, Left("SwapOutReceiver fee invoice"), CltvExpiryDelta(18)) + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapOutRequest = SwapOutRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + + val wallet = new DummyOnChainWallet() + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + + // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInReceiver(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + + withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path for new swap out") { f => + import f._ + + // start new SwapInReceiver + swapInReceiver ! StartSwapOutSender(amount, swapId, shortChannelId) + monitor.expectMessageType[StartSwapOutSender] + + // SwapInReceiver: SwapOutRequest -> SwapInSender + val request = register.expectMessageType[ForwardShortId[SwapOutRequest]].message + assert(request.pubkey == takerPubkey.toHex) + + // SwapInSender: SwapOutAgreement -> SwapInReceiver (request fee) + swapInReceiver ! SwapMessageReceived(SwapOutAgreement(request.protocolVersion, request.swapId, makerPubkey.toString(), feeInvoice.toString)) + monitor.expectMessageType[SwapMessageReceived] + + // SwapInReceiver validates fee invoice before paying the invoice + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(feeInvoice.amount_opt.get, feeInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapInStatus].behavior == "payFeeInvoice") + + // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver confirms the fee invoice has been paid + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), feeInvoice.paymentHash, feePreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), fee.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val feePaymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(feePaymentEvent.isInstanceOf[PaymentSent] && feePaymentEvent.paymentHash === feeInvoice.paymentHash) + + // SwapInReceiver reports status of awaiting opening transaction after paying claim invoice + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapInStatus].behavior == "payFeeInvoice") + + // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) + swapInReceiver ! SwapMessageReceived(openingTxBroadcasted) + monitor.expectMessageType[SwapMessageReceived] + + // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(request.amount.sat, makerPubkey, takerPubkey, paymentInvoice.paymentHash)), 0) + swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + monitor.expectMessageType[OpeningTxConfirmed] + + // SwapInReceiver validates invoice and opening transaction before paying the invoice + monitor.expectMessageType[ValidInvoice] + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(paymentInvoice.amount_opt.get, paymentInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + + // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver ignores payments that do not correspond to the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), ByteVector32.Zeroes, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + monitor.expectMessageType[PaymentEventReceived].paymentEvent + monitor.expectNoMessage() + + // SwapInReceiver commits a claim-by-invoice transaction after successfully paying the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), paymentInvoice.paymentHash, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val paymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(paymentEvent.isInstanceOf[PaymentSent] && paymentEvent.paymentHash === paymentInvoice.paymentHash) + monitor.expectMessage(ClaimTxCommitted) + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx(request.amount.sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + monitor.expectMessageType[ClaimTxConfirmed] + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala index f990885ab6..acfecf32aa 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala @@ -28,10 +28,10 @@ import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} -import fr.acinq.eclair.channel.Register.Forward -import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, DATA_NORMAL, RES_GET_CHANNEL_DATA} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} -import fr.acinq.eclair.swap.SwapData.SwapInSenderData +import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} import fr.acinq.eclair.swap.SwapRegister.{MessageReceived, SwapInRequested, SwapTerminated} import fr.acinq.eclair.swap.SwapResponses.{Response, SwapOpened} @@ -94,7 +94,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey.toString()) val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId, bobPubkey.toString(), premium) val openingTxBroadcasted: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txId, scriptOut, blindingKey) - val savedData: Set[SwapInSenderData] = Set(SwapInSenderData(channelId, swapInRequest, swapInAgreement, invoice, openingTxBroadcasted)) + val savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice, openingTxBroadcasted, isInitiator = true)) val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, savedData)), "SwapRegister") // wait for SwapInSender to subscribe to PaymentEventReceived messages @@ -123,16 +123,12 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app userCli.expectNoMessage() // User:SwapInRequested -> SwapInRegister - swapRegister ! SwapInRequested(userCli.ref, amount, channelId) + swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) val swapId = userCli.expectMessageType[SwapOpened].swapId monitor.expectMessageType[SwapInRequested] - // Alice will first request channel data to get shortChannelId - val getChannelData = register.expectMessageType[Forward[CMD_GET_CHANNEL_DATA]] - getChannelData.replyTo.toClassic ! RES_GET_CHANNEL_DATA(channelData) - // Alice:SwapInRequest -> Bob - val swapInRequest = register.expectMessageType[Forward[SwapInRequest]] + val swapInRequest = register.expectMessageType[ForwardShortId[SwapInRequest]] assert(swapId === swapInRequest.message.swapId) // Bob: SwapInAgreement -> Alice @@ -143,7 +139,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app swapEvents.expectMessageType[TransactionPublished] // Alice:OpeningTxBroadcasted -> Bob - val openingTxBroadcasted = register.expectMessageType[Forward[OpeningTxBroadcasted]] + val openingTxBroadcasted = register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] // Bob: payment(paymentHash) -> Alice val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get.paymentHash diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala index 96d40525b9..205f5324fe 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala @@ -27,11 +27,17 @@ trait PeerSwap { import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization} val swapIn: Route = postRequest("swapin") { implicit t => - formFields(channelIdFormParam, amountSatFormParam) { (channelId, amount) => + formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) => complete(eclairApi.swapIn(channelId, amount)) } } + val swapOut: Route = postRequest("swapout") { implicit t => + formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) => + complete(eclairApi.swapOut(channelId, amount)) + } + } + val listSwaps: Route = postRequest("listswaps") { implicit t => complete(eclairApi.listSwaps()) } @@ -42,6 +48,6 @@ trait PeerSwap { } } - val peerSwapRoutes: Route = swapIn ~ listSwaps ~ cancelSwap + val peerSwapRoutes: Route = swapIn ~ swapOut ~ listSwaps ~ cancelSwap } From c07b5525358dd6a0b917d0827dd3fdf79b4490c8 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 16 Sep 2022 13:08:02 +0200 Subject: [PATCH 15/23] Change Register to use simple swapId String for key --- .../fr/acinq/eclair/swap/SwapRegister.scala | 47 +++++++++---------- .../acinq/eclair/swap/SwapRegisterSpec.scala | 4 +- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala index f02f6ef3aa..060515db4e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala @@ -22,7 +22,7 @@ import akka.actor.typed.ActorRef.ActorRefOps import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.bitcoin.scalacompat.Satoshi import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.swap.SwapCommands._ @@ -31,7 +31,6 @@ import fr.acinq.eclair.swap.SwapRegister.Command import fr.acinq.eclair.swap.SwapResponses.{Response, Status, SwapOpened} import fr.acinq.eclair.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest} import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} -import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt import scala.concurrent.{Await, Future} @@ -48,13 +47,9 @@ object SwapRegister { case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages case class SwapOutRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages case class MessageReceived(message: HasSwapId) extends RegisteringMessages - case class SwapTerminated(swapInSenderId: SwapId) extends RegisteringMessages + case class SwapTerminated(swapId: String) extends RegisteringMessages case class ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) extends RegisteringMessages case class CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) extends RegisteringMessages with ReplyToMessages - - case class SwapId(id: String) { - def toByteVector32: ByteVector32 = ByteVector32(ByteVector.fromValidHex(id)) - } // @formatter:on def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData] = Set()): Behavior[Command] = Behaviors.setup { context => @@ -80,56 +75,56 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam val swaps = data.map { state => val swap: typed.ActorRef[SwapCommands.SwapCommand] = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) .onFailure(typed.SupervisorStrategy.restart), "SwapInSender-"+state.request.scid) - context.watchWith(swap, SwapTerminated(SwapId(state.request.swapId))) + context.watchWith(swap, SwapTerminated(state.request.swapId)) swap ! RestoreSwapInSender(state) - SwapId(state.request.swapId) -> swap.unsafeUpcast + state.request.swapId -> swap.unsafeUpcast }.toMap registering(swaps) } - private def registering(swaps: Map[SwapId, ActorRef[Any]]): Behavior[Command] = { + private def registering(swaps: Map[String, ActorRef[SwapCommands.SwapCommand]]): Behavior[Command] = { // TODO: fail requests for swaps on a channel if one already exists for the channel; keep a list of channels with active swaps myReceive[RegisteringMessages]("registering") { case SwapInRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex val swap = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) - .onFailure(SupervisorStrategy.restart), "Swap-"+shortChannelId.toHex) - context.watchWith(swap, SwapTerminated(SwapId(swapId))) + .onFailure(SupervisorStrategy.restart), "Swap-"+shortChannelId) + context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapInSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) - registering(swaps + (SwapId(swapId) -> swap.unsafeUpcast)) + registering(swaps + (swapId -> swap)) - case SwapOutRequested(replyTo, amount, channelId) => + case SwapOutRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex val swap = context.spawn(Behaviors.supervise(SwapInReceiver(nodeParams, paymentInitiator, watcher, register, wallet)) - .onFailure(SupervisorStrategy.restart), "Swap-" + channelId.toHex) - context.watchWith(swap, SwapTerminated(SwapId(swapId))) - swap ! StartSwapOutSender(amount, swapId, channelId) + .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) + context.watchWith(swap, SwapTerminated(swapId)) + swap ! StartSwapOutSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) - registering(swaps + (SwapId(swapId) -> swap.unsafeUpcast)) + registering(swaps + (swapId -> swap)) case MessageReceived(request: SwapInRequest) => val swap = context.spawn(Behaviors.supervise(SwapInReceiver(nodeParams, paymentInitiator, watcher, register, wallet)) - .onFailure(SupervisorStrategy.restart), "Swap-"+request.scid) - context.watchWith(swap, SwapTerminated(SwapId(request.swapId))) + .onFailure(SupervisorStrategy.restart), "Swap-"+ request.scid) + context.watchWith(swap, SwapTerminated(request.swapId)) swap ! StartSwapInReceiver(request) - registering(swaps + (SwapId(request.swapId) -> swap.unsafeUpcast)) + registering(swaps + (request.swapId -> swap)) case MessageReceived(request: SwapOutRequest) => val swap = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) - context.watchWith(swap, SwapTerminated(SwapId(request.swapId))) + context.watchWith(swap, SwapTerminated(request.swapId)) swap ! StartSwapOutReceiver(request) - registering(swaps + (SwapId(request.swapId) -> swap.unsafeUpcast)) + registering(swaps + (request.swapId -> swap)) - case MessageReceived(msg) => swaps.get(SwapId(msg.swapId)) match { + case MessageReceived(msg) => swaps.get(msg.swapId) match { case Some(swap) => swap ! SwapMessageReceived(msg) Behaviors.same case None => context.log.error(s"received unhandled message for swap ${msg.swapId}: $msg") Behaviors.same } - case SwapTerminated(swapInSenderId) => registering(swaps - SwapId(swapInSenderId.id)) + case SwapTerminated(swapId) => registering(swaps - swapId) case ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) => // TODO: is this the best way to do this?! @@ -138,7 +133,7 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam Behaviors.same case CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) => - swaps.get(SwapId(swapId)) match { + swaps.get(swapId) match { case Some(swap) => swap ! CancelRequested(replyTo) Behaviors.same case None => context.log.error(s"could not cancel swap $swapId: does not exist") diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala index acfecf32aa..fc21a81c84 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala @@ -109,7 +109,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app assert(swapEvents.expectMessageType[ClaimByInvoicePaid].swapId === swapId) // SwapRegister receives notification that the swap actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapInSenderId.id === swapId) + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId) testKit.stop(swapRegister) } @@ -150,7 +150,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app assert(swapEvents.expectMessageType[ClaimByInvoicePaid].swapId === swapId) // SwapRegister receives notification that the swap actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapInSenderId.id === swapId) + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId) testKit.stop(swapRegister) } From 00032d6cc71a619030c7278abe986005c101e676 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 16 Sep 2022 14:15:52 +0200 Subject: [PATCH 16/23] Rename actors to SwapMaker and SwapTaker --- .../fr/acinq/eclair/swap/SwapCommands.scala | 7 ++-- .../{SwapInSender.scala => SwapMaker.scala} | 35 ++++++++++-------- .../fr/acinq/eclair/swap/SwapRegister.scala | 19 +++++----- .../fr/acinq/eclair/swap/SwapResponses.scala | 2 +- .../{SwapInReceiver.scala => SwapTaker.scala} | 37 ++++++++++--------- .../eclair/swap/SwapInReceiverSpec.scala | 12 +++--- .../acinq/eclair/swap/SwapInSenderSpec.scala | 16 ++++---- .../eclair/swap/SwapOutReceiverSpec.scala | 6 +-- .../acinq/eclair/swap/SwapOutSenderSpec.scala | 14 +++---- .../acinq/eclair/swap/SwapRegisterSpec.scala | 2 +- 10 files changed, 78 insertions(+), 72 deletions(-) rename eclair-core/src/main/scala/fr/acinq/eclair/swap/{SwapInSender.scala => SwapMaker.scala} (91%) rename eclair-core/src/main/scala/fr/acinq/eclair/swap/{SwapInReceiver.scala => SwapTaker.scala} (90%) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala index 1109adf8d9..af6cfdfd94 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala @@ -34,8 +34,8 @@ object SwapCommands { // @formatter:off case class StartSwapInSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand case class StartSwapOutReceiver(request: SwapOutRequest) extends SwapCommand - case class RestoreSwapInSender(swapData: SwapData) extends SwapCommand - case object AbortSwapInSender extends SwapCommand + case class RestoreSwapMaker(swapData: SwapData) extends SwapCommand + case object AbortSwap extends SwapCommand sealed trait CreateSwapMessages extends SwapCommand case object StateTimeout extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with ClaimSwapCsvMessages with WaitCsvMessages with AwaitFeePaymentMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages @@ -77,8 +77,7 @@ object SwapCommands { // @formatter:off case class StartSwapInReceiver(request: SwapInRequest) extends SwapCommand case class StartSwapOutSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand - case class RestoreSwapInReceiver(swapData: SwapData) extends SwapCommand - case object AbortSwapInReceiver extends SwapCommand + case class RestoreSwapTaker(swapData: SwapData) extends SwapCommand sealed trait SendAgreementMessages extends SwapCommand sealed trait AwaitFeePaymentMessages extends SwapCommand diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala similarity index 91% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala rename to eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala index f1e94f9cb0..819bfdb45c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala @@ -34,7 +34,7 @@ import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapHelpers._ -import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapInStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta import fr.acinq.eclair.swap.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx} @@ -45,10 +45,11 @@ import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt import scala.util.{Failure, Success} -object SwapInSender { +object SwapMaker { /* - SwapInSender SwapInReceiver + SwapMaker SwapTaker + "Swap Out" RESPONDER INITIATOR | | [createSwap] | SwapOutRequest | @@ -68,6 +69,7 @@ object SwapInSender { |------------------------------->| | | [awaitOpeningTxConfirmed] + "Swap In" INITIATOR RESPONDER [createSwap] | | | SwapInRequest | @@ -91,6 +93,7 @@ object SwapInSender { | | [claimSwap] (claim_by_invoice) "Refund Cooperatively" + | | | CoopClose | [sendCoopClose] |<-------------------------------| (claim_by_coop) [claimSwapCoop] | | @@ -106,28 +109,28 @@ object SwapInSender { Behaviors.setup { context => Behaviors.receiveMessagePartial { case StartSwapInSender(amount, swapId, shortChannelId) => - new SwapInSender(shortChannelId, nodeParams, watcher, register, wallet, context) + new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, context) .createSwap(amount, swapId) case StartSwapOutReceiver(request: SwapOutRequest) => ShortChannelId.fromCoordinates(request.scid) match { - case Success(shortChannelId) => new SwapInSender(shortChannelId, nodeParams, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, context) .validateRequest(request) case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped } - case RestoreSwapInSender(d) => + case RestoreSwapMaker(d) => ShortChannelId.fromCoordinates(d.request.scid) match { - case Success(shortChannelId) => new SwapInSender(shortChannelId, nodeParams, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, context) .awaitClaimPayment(d.request, d.agreement, d.invoice, d.openingTxBroadcasted, d.isInitiator) case Failure(e) => context.log.error(s"could not restore swap sender with invalid shortChannelId: $d, $e") Behaviors.stopped } - case AbortSwapInSender => Behaviors.stopped + case AbortSwap => Behaviors.stopped } } } -private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { +private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds @@ -180,7 +183,7 @@ private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParam case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) Behaviors.same } } @@ -201,7 +204,7 @@ private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParam case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitAgreement", request) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) Behaviors.same } } @@ -230,7 +233,7 @@ private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParam swapCanceled(InternalError(request.swapId, "timeout during CreateOpeningTx")) case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same // ignore - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) Behaviors.same } } @@ -253,7 +256,7 @@ private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParam waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } @@ -281,7 +284,7 @@ private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParam case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } @@ -300,7 +303,7 @@ private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParam Behaviors.same case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } @@ -324,7 +327,7 @@ private class SwapInSender(shortChannelId: ShortChannelId, nodeParams: NodeParam Behaviors.same case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala index 060515db4e..394aca951c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala @@ -70,13 +70,14 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam } private def initializing: Behavior[Command] = { - // TODO: restore SwapInReceiver from 'data' + // TODO: restore SwapTaker from 'data' // TODO: restore 'data' from database val swaps = data.map { state => - val swap: typed.ActorRef[SwapCommands.SwapCommand] = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) - .onFailure(typed.SupervisorStrategy.restart), "SwapInSender-"+state.request.scid) + val swap: typed.ActorRef[SwapCommands.SwapCommand] = + context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) + .onFailure(typed.SupervisorStrategy.restart), "SwapMaker-"+state.request.scid) context.watchWith(swap, SwapTerminated(state.request.swapId)) - swap ! RestoreSwapInSender(state) + swap ! RestoreSwapMaker(state) state.request.swapId -> swap.unsafeUpcast }.toMap registering(swaps) @@ -87,8 +88,8 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam myReceive[RegisteringMessages]("registering") { case SwapInRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex - val swap = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) - .onFailure(SupervisorStrategy.restart), "Swap-"+shortChannelId) + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) + .onFailure(SupervisorStrategy.restart), "SwapMaker-"+shortChannelId) context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapInSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) @@ -96,7 +97,7 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam case SwapOutRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex - val swap = context.spawn(Behaviors.supervise(SwapInReceiver(nodeParams, paymentInitiator, watcher, register, wallet)) + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet)) .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapOutSender(amount, swapId, shortChannelId) @@ -104,14 +105,14 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam registering(swaps + (swapId -> swap)) case MessageReceived(request: SwapInRequest) => - val swap = context.spawn(Behaviors.supervise(SwapInReceiver(nodeParams, paymentInitiator, watcher, register, wallet)) + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet)) .onFailure(SupervisorStrategy.restart), "Swap-"+ request.scid) context.watchWith(swap, SwapTerminated(request.swapId)) swap ! StartSwapInReceiver(request) registering(swaps + (request.swapId -> swap)) case MessageReceived(request: SwapOutRequest) => - val swap = context.spawn(Behaviors.supervise(SwapInSender(nodeParams, watcher, register, wallet)) + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) context.watchWith(swap, SwapTerminated(request.swapId)) swap ! StartSwapOutReceiver(request) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala index 8529455a72..4a426841d9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala @@ -65,7 +65,7 @@ object SwapResponses { sealed trait Status extends Response - case class SwapInStatus(swapId: String, actor: String, behavior: String, request: SwapRequest, agreement_opt: Option[SwapAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None) extends Status { + case class SwapStatus(swapId: String, actor: String, behavior: String, request: SwapRequest, agreement_opt: Option[SwapAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None) extends Status { override def toString: String = s"$actor[$behavior]: $swapId, ${request.scid}, $request, $agreement_opt, $invoice_opt, $openingTxBroadcasted_opt" } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala similarity index 90% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala rename to eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala index 509ee0b557..5a4a8ed576 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala @@ -31,7 +31,7 @@ import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentFailed, Paym import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapHelpers._ -import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapInStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} import fr.acinq.eclair.swap.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions.SwapClaimByCoopTx import fr.acinq.eclair.wire.protocol._ @@ -41,10 +41,11 @@ import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt import scala.util.{Failure, Success} -object SwapInReceiver { +object SwapTaker { /* - SwapInSender SwapInReceiver + SwapMaker SwapTaker + "Swap Out" RESPONDER INITIATOR | | [createSwap] | SwapOutRequest | @@ -64,6 +65,7 @@ object SwapInReceiver { |------------------------------->| | | [awaitOpeningTxConfirmed] + "Swap In" INITIATOR RESPONDER [createSwap] | | | SwapInRequest | @@ -87,6 +89,7 @@ object SwapInReceiver { | | [claimSwap] (claim_by_invoice) "Refund Cooperatively" + | | | CoopClose | [sendCoopClose] |<-------------------------------| (claim_by_coop) [claimSwapCoop] | | @@ -102,28 +105,28 @@ object SwapInReceiver { Behaviors.setup { context => Behaviors.receiveMessagePartial { case StartSwapOutSender(amount, swapId, shortChannelId) => - new SwapInReceiver(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) .createSwap(amount, swapId) case StartSwapInReceiver(request: SwapInRequest) => ShortChannelId.fromCoordinates(request.scid) match { - case Success(shortChannelId) => new SwapInReceiver(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) .validateRequest(request) case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped } - case RestoreSwapInReceiver(d) => + case RestoreSwapTaker(d) => ShortChannelId.fromCoordinates(d.request.scid) match { - case Success(shortChannelId) => new SwapInReceiver(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) .awaitOpeningTxConfirmed(d.request, d.agreement, d.openingTxBroadcasted, d.isInitiator) case Failure(e) => context.log.error(s"could not restore swap receiver with invalid shortChannelId: $d, $e") Behaviors.stopped } - case AbortSwapInReceiver => Behaviors.stopped + case AbortSwap => Behaviors.stopped } } } -private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { +private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds @@ -161,7 +164,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitAgreement", request) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) Behaviors.same } } @@ -197,7 +200,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during payFeeInvoice")) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(CreateFailed(request.swapId, s"Cancel requested by user while validating opening tx.")) - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) Behaviors.same } } @@ -223,7 +226,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) sendCoopClose(request, s"Cancel requested by user after sending agreement.") - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) Behaviors.same } } @@ -239,7 +242,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar case InvoiceExpired => sendCoopClose(request, "Timeout waiting for opening tx to confirm.") case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) sendCoopClose(request, s"Cancel requested by user while waiting for opening tx to confirm.") - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } @@ -264,7 +267,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during validateOpeningTx: $m", Some(openingTxBroadcasted)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) sendCoopClose(request, s"Cancel requested by user while validating opening tx.", Some(openingTxBroadcasted)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "validateOpeningTx", request, Some(agreement), None, Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "validateOpeningTx", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } @@ -280,7 +283,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(request, s"Lightning payment failed (invalid PaymentEvent received: $p).", Some(openingTxBroadcasted)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) sendCoopClose(request, s"Cancel requested by user while paying claim invoice.", Some(openingTxBroadcasted)) - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } @@ -306,7 +309,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar case StateTimeout => Behaviors.same // TODO: handle when claim tx not confirmed, retry or RBF the tx? can SwapInSender pin this tx with a low fee? case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after claim tx committed.") Behaviors.same // ignore - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } @@ -323,7 +326,7 @@ private class SwapInReceiver(shortChannelId: ShortChannelId, nodeParams: NodePar // TODO: set long enough timeout delay to wait for counterparty to sweep opening tx case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCompleted(ClaimByCoopOffered(request.swapId, reason + "+ user canceled while waiting for opening tx to be swept by counter party.")) - case GetStatus(replyTo) => replyTo ! SwapInStatus(request.swapId, context.self.toString, "sendCoopClose", request, None, None, openingTxBroadcasted_opt) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "sendCoopClose", request, None, None, openingTxBroadcasted_opt) Behaviors.same } case None => swapCompleted(ClaimByCoopOffered(request.swapId, reason)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala index e9f7759584..567c74b00d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala @@ -36,7 +36,7 @@ import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} @@ -92,7 +92,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInReceiver(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-receiver") withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } @@ -106,13 +106,13 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = false) - swapInReceiver ! RestoreSwapInReceiver(swapData) - monitor.expectMessageType[RestoreSwapInReceiver] + swapInReceiver ! RestoreSwapTaker(swapData) + monitor.expectMessageType[RestoreSwapTaker] // SwapInReceiver reports status of awaiting opening transaction swapInReceiver ! GetStatus(userCli.ref) monitor.expectMessageType[GetStatus] - assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitOpeningTxConfirmed") + assert(userCli.expectMessageType[SwapStatus].behavior == "awaitOpeningTxConfirmed") // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut((request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash)), 0) @@ -189,7 +189,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // SwapInReceiver reports status of awaiting claim by invoice tx to confirm swapInReceiver ! GetStatus(userCli.ref) monitor.expectMessageType[GetStatus] - assert(userCli.expectMessageType[SwapInStatus].behavior == "claimSwap") + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwap") // SwapInReceiver reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala index e756fbcee3..d9d6b3df97 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala @@ -35,7 +35,7 @@ import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapEvents._ -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol.{CoopClose, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} @@ -89,7 +89,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // subscribe to notification events from SwapInSender when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInSender(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, watcher, wallet, swapEvents))) } @@ -103,7 +103,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) - swapInSender ! RestoreSwapInSender(swapData) + swapInSender ! RestoreSwapMaker(swapData) // resend OpeningTxBroadcasted when swap restored register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] @@ -153,7 +153,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // SwapInSender reports status of awaiting payment swapInSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitClaimPayment") + assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") // SwapInSender receives a payment with the corresponding payment hash // TODO: convert from ShortChannelId to ByteVector32 @@ -174,7 +174,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) - swapInSender ! RestoreSwapInSender(swapData) + swapInSender ! RestoreSwapMaker(swapData) // resend OpeningTxBroadcasted when swap restored register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] @@ -190,7 +190,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // SwapInSender reports status of awaiting claim by cooperative close tx to confirm swapInSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapInStatus].behavior == "claimSwapCoop") + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwapCoop") // ZmqWatcher -> SwapInSender, trigger confirmation of coop close transaction swapEvents.expectMessageType[TransactionPublished] @@ -211,7 +211,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo expirySeconds = Some(2)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) - swapInSender ! RestoreSwapInSender(swapData) + swapInSender ! RestoreSwapMaker(swapData) // resend OpeningTxBroadcasted when swap restored register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] @@ -224,7 +224,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // SwapInSender reports status of awaiting claim by csv tx to confirm swapInSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapInStatus].behavior == "claimSwapCsv") + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwapCsv") // watch for and trigger that the claim-by-csv tx has been confirmed on chain watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(0), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala index cd0dde6106..2cc57eb13f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala @@ -33,7 +33,7 @@ import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} import fr.acinq.eclair.swap.SwapTransactions.openingTxWeight import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} @@ -88,7 +88,7 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInSender(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-out-receiver") withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } @@ -125,7 +125,7 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory // SwapInSender reports status of awaiting payment swapInSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapInStatus].behavior == "awaitClaimPayment") + assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") // SwapInSender receives a payment with the corresponding payment hash // TODO: convert from ShortChannelId to ByteVector32 diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala index fb98599e7b..f30980b4de 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala @@ -35,7 +35,7 @@ import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapInStatus} +import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} import fr.acinq.eclair.swap.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} @@ -68,9 +68,9 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l val feeRatePerKw: FeeratePerKw = TestConstants.Bob.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Bob.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) val paymentPreimage: ByteVector32 = ByteVector32.One val feePreimage: ByteVector32 = ByteVector32.Zeroes - val paymentInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapInReceiver payment invoice"), CltvExpiryDelta(18)) - val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(feePreimage), makerPrivkey, Left("SwapOutReceiver fee invoice"), CltvExpiryDelta(18)) - val otherInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), randomBytes32(), makerPrivkey, Left("SwapOutReceiver fee invoice"), CltvExpiryDelta(18)) + val paymentInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapOutSender payment invoice"), CltvExpiryDelta(18)) + val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(feePreimage), makerPrivkey, Left("SwapOutSender fee invoice"), CltvExpiryDelta(18)) + val otherInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), randomBytes32(), makerPrivkey, Left("SwapOutSender other invoice"), CltvExpiryDelta(18)) val txid: String = ByteVector32.One.toHex val scriptOut: Long = 0 val blindingKey: String = "" @@ -94,7 +94,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapInReceiver(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-out-sender") withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } @@ -120,7 +120,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(feeInvoice.amount_opt.get, feeInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) swapInReceiver ! GetStatus(userCli.ref) monitor.expectMessageType[GetStatus] - assert(userCli.expectMessageType[SwapInStatus].behavior == "payFeeInvoice") + assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") // wait for SwapInReceiver to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -133,7 +133,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l // SwapInReceiver reports status of awaiting opening transaction after paying claim invoice swapInReceiver ! GetStatus(userCli.ref) monitor.expectMessageType[GetStatus] - assert(userCli.expectMessageType[SwapInStatus].behavior == "payFeeInvoice") + assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala index fc21a81c84..f61f77a356 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala @@ -66,7 +66,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val alicePrivkey: PrivateKey = PrivateKey(randomBytes32()) val alicePubkey: PublicKey = alicePrivkey.publicKey val bobPubkey: PublicKey = PrivateKey(randomBytes32()).publicKey - val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, alicePrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) + val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, alicePrivkey, Left("PeerSwap invoice"), CltvExpiryDelta(18)) val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) override def withFixture(test: OneArgTest): Outcome = { From 1270c07eca3948cd9c1d3f5c2a0cce63f367cd11 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Mon, 19 Sep 2022 13:14:15 +0200 Subject: [PATCH 17/23] Add swaps db and update swap actors to use it --- .../main/scala/fr/acinq/eclair/Setup.scala | 2 +- .../scala/fr/acinq/eclair/db/Databases.scala | 5 + .../fr/acinq/eclair/db/DualDatabases.scala | 34 +++++ .../scala/fr/acinq/eclair/db/SwapsDb.scala | 81 +++++++++++ .../fr/acinq/eclair/db/pg/PgSwapsDb.scala | 100 ++++++++++++++ .../eclair/db/sqlite/SqliteSwapsDb.scala | 87 ++++++++++++ .../fr/acinq/eclair/swap/SwapCommands.scala | 3 +- .../scala/fr/acinq/eclair/swap/SwapData.scala | 11 +- .../fr/acinq/eclair/swap/SwapEvents.scala | 6 +- .../fr/acinq/eclair/swap/SwapMaker.scala | 15 +- .../fr/acinq/eclair/swap/SwapRegister.scala | 23 ++-- .../fr/acinq/eclair/swap/SwapResponses.scala | 4 +- .../fr/acinq/eclair/swap/SwapTaker.scala | 15 +- .../scala/fr/acinq/eclair/TestDatabases.scala | 1 + .../fr/acinq/eclair/db/SwapsDbSpec.scala | 129 ++++++++++++++++++ .../basic/fixtures/MinimalNodeFixture.scala | 5 +- .../eclair/swap/SwapInReceiverSpec.scala | 7 +- .../acinq/eclair/swap/SwapInSenderSpec.scala | 13 +- .../acinq/eclair/swap/SwapOutSenderSpec.scala | 46 +++---- .../acinq/eclair/swap/SwapRegisterSpec.scala | 89 ++++++++---- 20 files changed, 583 insertions(+), 93 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/db/SwapsDb.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgSwapsDb.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteSwapsDb.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/db/SwapsDbSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index 557674f831..64dd566a26 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -306,7 +306,7 @@ class Setup(val datadir: File, txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, watcher, bitcoinClient) channelFactory = Peer.SimpleChannelFactory(nodeParams, watcher, relayer, bitcoinClient, txPublisherFactory) paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, PaymentInitiator.SimplePaymentFactory(nodeParams, router, register)), "payment-initiator", SupervisorStrategy.Restart)) - swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcher, register, bitcoinClient)).onFailure(typed.SupervisorStrategy.resume), "swap-register") + swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcher, register, bitcoinClient, nodeParams.db.swaps.restore().toSet)).onFailure(typed.SupervisorStrategy.resume), "swap-register") peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory, swapRegister) switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, peerFactory), "switchboard", SupervisorStrategy.Resume)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index 9713cfbf1b..def99e9735 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -44,6 +44,7 @@ trait Databases { def peers: PeersDb def payments: PaymentsDb def pendingCommands: PendingCommandsDb + def swaps: SwapsDb //@formatter:on } @@ -65,6 +66,7 @@ object Databases extends Logging { peers: SqlitePeersDb, payments: SqlitePaymentsDb, pendingCommands: SqlitePendingCommandsDb, + swaps: SqliteSwapsDb, private val backupConnection: Connection) extends Databases with FileBackup { override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) { statement => { @@ -83,6 +85,7 @@ object Databases extends Logging { peers = new SqlitePeersDb(eclairJdbc), payments = new SqlitePaymentsDb(eclairJdbc), pendingCommands = new SqlitePendingCommandsDb(eclairJdbc), + swaps = new SqliteSwapsDb(eclairJdbc), backupConnection = eclairJdbc ) } @@ -95,6 +98,7 @@ object Databases extends Logging { payments: PgPaymentsDb, pendingCommands: PgPendingCommandsDb, dataSource: HikariDataSource, + swaps: PgSwapsDb, lock: PgLock) extends Databases with ExclusiveLock { override def obtainExclusiveLock(): Unit = lock.obtainExclusiveLock(dataSource) } @@ -154,6 +158,7 @@ object Databases extends Logging { peers = new PgPeersDb, payments = new PgPaymentsDb, pendingCommands = new PgPendingCommandsDb, + swaps = new PgSwapsDb, dataSource = ds, lock = lock) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala index f65d993be3..8481de32e0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -9,6 +9,8 @@ import fr.acinq.eclair.db.DualDatabases.runAsync import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.router.Router +import fr.acinq.eclair.swap.SwapData +import fr.acinq.eclair.swap.SwapEvents.SwapEvent import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli} import grizzled.slf4j.Logging @@ -39,6 +41,8 @@ case class DualDatabases(primary: Databases, secondary: Databases) extends Datab override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands) + override val swaps: SwapsDb = DualSwapsDb(primary.swaps, secondary.swaps) + /** if one of the database supports file backup, we use it */ override def backup(backupFile: File): Unit = (primary, secondary) match { case (f: FileBackup, _) => f.backup(backupFile) @@ -388,3 +392,33 @@ case class DualPendingCommandsDb(primary: PendingCommandsDb, secondary: PendingC primary.listSettlementCommands() } } + +case class DualSwapsDb(primary: SwapsDb, secondary: SwapsDb) extends SwapsDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build())) + + override def add(swapData: SwapData): Unit = { + runAsync(secondary.add(swapData)) + primary.add(swapData) + } + + override def addResult(swapEvent: SwapEvent): Unit = { + runAsync(secondary.addResult(swapEvent)) + primary.addResult(swapEvent) + } + + override def remove(swapId: String): Unit = { + runAsync(secondary.remove(swapId)) + primary.remove(swapId) + } + + override def restore(): Seq[SwapData] = { + runAsync(secondary.restore()) + primary.restore() + } + + override def list(): Seq[SwapData] = { + runAsync(secondary.list()) + primary.list() + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/SwapsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/SwapsDb.scala new file mode 100644 index 0000000000..2be2ce2d2a --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/SwapsDb.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db + +import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.swap.SwapEvents.SwapEvent +import fr.acinq.eclair.swap.SwapRole.Maker +import fr.acinq.eclair.swap.{SwapData, SwapRole} +import fr.acinq.eclair.wire.protocol._ +import org.json4s.jackson.JsonMethods.{compact, parse, render} +import org.json4s.jackson.Serialization + +import java.sql.{PreparedStatement, ResultSet} + +trait SwapsDb { + + def add(swapData: SwapData): Unit + + def addResult(swapEvent: SwapEvent): Unit + + def remove(swapId: String): Unit + + def restore(): Seq[SwapData] + + def list(): Seq[SwapData] + +} + +object SwapsDb { + import fr.acinq.eclair.json.JsonSerializers.formats + + def setSwapData(statement: PreparedStatement, swapData: SwapData): Unit = { + statement.setString(1, swapData.request.swapId) + statement.setString(2, Serialization.write(swapData.request)) + statement.setString(3, Serialization.write(swapData.agreement)) + statement.setString(4, swapData.invoice.toString) + statement.setString(5, Serialization.write(swapData.openingTxBroadcasted)) + statement.setInt(6, swapData.swapRole.id) + statement.setBoolean(7, swapData.isInitiator) + statement.setString(8, "") + } + + def getSwapData(rs: ResultSet): SwapData = { + val isInitiator = rs.getBoolean("is_initiator") + val isMaker = SwapRole(rs.getInt("swap_role")) == Maker + val request_json = rs.getString("request") + val agreement_json = rs.getString("agreement") + val openingTxBroadcasted_json = rs.getString("opening_tx_broadcasted") + val (request, agreement) = (isInitiator, isMaker) match { + case (true, true) => (Serialization.read[SwapInRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapInAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + case (false, false) => (Serialization.read[SwapInRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapInAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + case (true, false) => (Serialization.read[SwapOutRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapOutAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + case (false, true) => (Serialization.read[SwapOutRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapOutAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + } + SwapData( + request, + agreement, + Bolt11Invoice.fromString(rs.getString("invoice")).get, + Serialization.read[OpeningTxBroadcasted](compact(render(parse(openingTxBroadcasted_json).camelizeKeys))), + SwapRole(rs.getInt("swap_role")), + rs.getBoolean("is_initiator")) + } +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgSwapsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgSwapsDb.scala new file mode 100644 index 0000000000..cfacf49137 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgSwapsDb.scala @@ -0,0 +1,100 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db.pg + +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +import fr.acinq.eclair.db.SwapsDb +import fr.acinq.eclair.db.SwapsDb.{getSwapData, setSwapData} +import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock +import fr.acinq.eclair.swap.SwapData +import fr.acinq.eclair.swap.SwapEvents.SwapEvent +import grizzled.slf4j.Logging + +import javax.sql.DataSource + +object PgSwapsDb { + val DB_NAME = "swaps" + val CURRENT_VERSION = 1 +} + +class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { + + import PgUtils._ + import ExtendedResultSet._ + import PgSwapsDb._ + + inTransaction { pg => + using(pg.createStatement(), inTransaction = true) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE TABLE swaps (swap_id TEXT NOT NULL PRIMARY KEY, request TEXT NOT NULL, agreement TEXT NOT NULL, invoice TEXT NOT NULL, opening_tx_broadcasted TEXT NOT NULL, swap_role BIGINT NOT NULL, is_initiator BOOLEAN NOT NULL, result TEXT NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + } + + override def add(swapData: SwapData): Unit = withMetrics("swaps/add", DbBackends.Postgres) { + inTransaction { pg => + using(pg.prepareStatement( + """INSERT INTO swaps (swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result) + VALUES (?, ?::JSON, ?::JSON, ?, ?::JSON, ?, ?, ?) ON CONFLICT (swap_id) DO NOTHING""")) { statement => + setSwapData(statement, swapData) + statement.executeUpdate() + } + } + } + + override def addResult(swapEvent: SwapEvent): Unit = withMetrics("swaps/add_result", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("UPDATE swaps SET result=? WHERE swap_id=?")) { statement => + statement.setString(1, swapEvent.toString) + statement.setString(2, swapEvent.swapId) + statement.executeUpdate() + } + } + } + + override def remove(swapId: String): Unit = withMetrics("swaps/remove", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("DELETE FROM swaps WHERE swap_id=?")) { statement => + statement.setString(1, swapId) + statement.executeUpdate() + } + } + } + + override def restore(): Seq[SwapData] = withMetrics("swaps/restore", DbBackends.Postgres) { + inTransaction { pg => + using(pg.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps WHERE result=?")) { statement => + statement.setString(1, "") + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + } + + override def list(): Seq[SwapData] = withMetrics("swaps/list", DbBackends.Postgres) { + inTransaction { pg => + using(pg.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps")) { statement => + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + } + +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteSwapsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteSwapsDb.scala new file mode 100644 index 0000000000..56f13623d7 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteSwapsDb.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db.sqlite + +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +import fr.acinq.eclair.db.SwapsDb +import fr.acinq.eclair.db.SwapsDb.{getSwapData, setSwapData} +import fr.acinq.eclair.swap.SwapData +import fr.acinq.eclair.swap.SwapEvents.SwapEvent +import grizzled.slf4j.Logging + +import java.sql.Connection + +object SqliteSwapsDb { + val DB_NAME = "swaps" + val CURRENT_VERSION = 1 +} + +class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { + + import SqliteUtils._ + import ExtendedResultSet._ + import SqliteSwapsDb._ + + using(sqlite.createStatement(), inTransaction = true) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE TABLE swaps (swap_id STRING NOT NULL PRIMARY KEY, request STRING NOT NULL, agreement STRING NOT NULL, invoice STRING NOT NULL, opening_tx_broadcasted STRING NOT NULL, swap_role INTEGER NOT NULL, is_initiator BOOLEAN NOT NULL, result STRING NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + + override def add(swapData: SwapData): Unit = withMetrics("swaps/add", DbBackends.Sqlite) { + using(sqlite.prepareStatement( + """INSERT INTO swaps (swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (swap_id) DO NOTHING""")) { statement => + setSwapData(statement, swapData) + statement.executeUpdate() + } + } + + override def addResult(swapEvent: SwapEvent): Unit = withMetrics("swaps/add_result", DbBackends.Sqlite) { + using(sqlite.prepareStatement("UPDATE swaps SET result=? WHERE swap_id=?")) { statement => + statement.setString(1, swapEvent.toString) + statement.setString(2, swapEvent.swapId) + statement.executeUpdate() + } + } + + override def remove(swapId: String): Unit = withMetrics("swaps/remove", DbBackends.Sqlite) { + using(sqlite.prepareStatement("DELETE FROM swaps WHERE swap_id=?")) { statement => + statement.setString(1, swapId) + statement.executeUpdate() + } + } + + override def restore(): Seq[SwapData] = withMetrics("swaps/restore", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps WHERE result=?")) { statement => + statement.setString(1, "") + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + + override def list(): Seq[SwapData] = withMetrics("swaps/list", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps")) { statement => + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala index af6cfdfd94..79037bbdd0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala @@ -34,7 +34,7 @@ object SwapCommands { // @formatter:off case class StartSwapInSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand case class StartSwapOutReceiver(request: SwapOutRequest) extends SwapCommand - case class RestoreSwapMaker(swapData: SwapData) extends SwapCommand + case class RestoreSwap(swapData: SwapData) extends SwapCommand case object AbortSwap extends SwapCommand sealed trait CreateSwapMessages extends SwapCommand @@ -77,7 +77,6 @@ object SwapCommands { // @formatter:off case class StartSwapInReceiver(request: SwapInRequest) extends SwapCommand case class StartSwapOutSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand - case class RestoreSwapTaker(swapData: SwapData) extends SwapCommand sealed trait SendAgreementMessages extends SwapCommand sealed trait AwaitFeePaymentMessages extends SwapCommand diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala index c7aa2b7aa4..8372181e1d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala @@ -17,8 +17,17 @@ package fr.acinq.eclair.swap import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.swap +import fr.acinq.eclair.swap.SwapRole.SwapRole import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapAgreement, SwapRequest} +object SwapRole extends Enumeration { + type SwapRole = Value + val Maker: swap.SwapRole.Value = Value(1, "Maker") + val Taker: swap.SwapRole.Value = Value(2, "Taker") +} + +case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, swapRole: SwapRole, isInitiator: Boolean) + object SwapData { - final case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala index 164cd99c43..94046592b2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala @@ -21,9 +21,11 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered import fr.acinq.eclair.payment.PaymentReceived object SwapEvents { - sealed trait SwapEvent + sealed trait SwapEvent { + def swapId: String + } - case class Canceled(swapId: String) extends SwapEvent + case class Canceled(swapId: String, reason: String) extends SwapEvent case class TransactionPublished(swapId: String, tx: Transaction, desc: String) extends SwapEvent case class TransactionConfirmed(swapId: String, tx: Transaction) extends SwapEvent case class ClaimByInvoiceConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala index 819bfdb45c..e1447cde19 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala @@ -35,6 +35,7 @@ import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapHelpers._ import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapRole.Maker import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta import fr.acinq.eclair.swap.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx} @@ -118,7 +119,7 @@ object SwapMaker { case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped } - case RestoreSwapMaker(d) => + case RestoreSwap(d) => ShortChannelId.fromCoordinates(d.request.scid) match { case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, context) .awaitClaimPayment(d.request, d.agreement, d.invoice, d.openingTxBroadcasted, d.isInitiator) @@ -176,7 +177,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= invoice.amount_opt.get => createOpeningTx(request, agreement, isInitiator = false) case PaymentEventReceived(_) => Behaviors.same - case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitFeePayment", m)) case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitFeePayment")) case InvoiceExpired => swapCanceled(InternalError(request.swapId, "fee payment invoice expired")) @@ -198,7 +199,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, case SwapMessageReceived(agreement: SwapInAgreement) if agreement.premium > maxPremium => swapCanceled(InternalError(request.swapId, "unacceptable premium requested.")) case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement, isInitiator = true) - case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitAgreement")) case ForwardFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap request to peer.")) case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) @@ -218,9 +219,11 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, case InvoiceResponse(invoice: Bolt11Invoice) => fundOpening(wallet, feeRatePerKw)((request.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) Behaviors.same // TODO: checkpoint PersistentSwapData for this swap to a database before committing the opening tx - case OpeningTxFunded(invoice, fundingResponse) => commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") + case OpeningTxFunded(invoice, fundingResponse) => + commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") Behaviors.same case OpeningTxCommitted(invoice, openingTxBroadcasted) => + nodeParams.db.swaps.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator)) awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted, isInitiator) case OpeningTxFailed(error, None) => swapCanceled(InternalError(request.swapId, s"failed to fund swap open tx, error: $error")) case OpeningTxFailed(error, Some(r)) => rollback(wallet)(error, r.fundingTx) @@ -335,11 +338,13 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { context.system.eventStream ! Publish(event) context.log.info(s"completed swap: $event.") + nodeParams.db.swaps.addResult(event) Behaviors.stopped } def swapCanceled(failure: Fail): Behavior[SwapCommand] = { - context.system.eventStream ! Publish(Canceled(failure.swapId)) + val swapEvent = Canceled(failure.swapId, failure.toString) + context.system.eventStream ! Publish(swapEvent) if (!failure.isInstanceOf[PeerCanceled]) sendShortId(register, shortChannelId)(CancelSwap(failure.swapId, failure.toString)) failure match { case e: Error => context.log.error(s"canceled swap: $e") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala index 394aca951c..e15521a531 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala @@ -26,7 +26,6 @@ import fr.acinq.bitcoin.scalacompat.Satoshi import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapRegister.Command import fr.acinq.eclair.swap.SwapResponses.{Response, Status, SwapOpened} import fr.acinq.eclair.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest} @@ -52,12 +51,12 @@ object SwapRegister { case class CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) extends RegisteringMessages with ReplyToMessages // @formatter:on - def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData] = Set()): Behavior[Command] = Behaviors.setup { context => + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData]): Behavior[Command] = Behaviors.setup { context => new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, wallet, data).initializing } } -private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData] = Set()) { +private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData]) { import SwapRegister._ private def myReceive[B <: Command : ClassTag](stateName: String)(f: B => Behavior[Command]): Behavior[Command] = @@ -70,14 +69,17 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam } private def initializing: Behavior[Command] = { - // TODO: restore SwapTaker from 'data' - // TODO: restore 'data' from database val swaps = data.map { state => - val swap: typed.ActorRef[SwapCommands.SwapCommand] = - context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) - .onFailure(typed.SupervisorStrategy.restart), "SwapMaker-"+state.request.scid) + val swap: typed.ActorRef[SwapCommands.SwapCommand] = { + state.swapRole match { + case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) + .onFailure(typed.SupervisorStrategy.restart), "SwapMaker-" + state.request.scid) + case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet)) + .onFailure(typed.SupervisorStrategy.restart), "SwapTaker-" + state.request.scid) + } + } context.watchWith(swap, SwapTerminated(state.request.swapId)) - swap ! RestoreSwapMaker(state) + swap ! RestoreSwap(state) state.request.swapId -> swap.unsafeUpcast }.toMap registering(swaps) @@ -85,11 +87,12 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam private def registering(swaps: Map[String, ActorRef[SwapCommands.SwapCommand]]): Behavior[Command] = { // TODO: fail requests for swaps on a channel if one already exists for the channel; keep a list of channels with active swaps + // TODO: check currently registered swaps, and swap db, to prevent reuse of a swapId myReceive[RegisteringMessages]("registering") { case SwapInRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) - .onFailure(SupervisorStrategy.restart), "SwapMaker-"+shortChannelId) + .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapInSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala index 4a426841d9..1846a8574f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala @@ -39,8 +39,8 @@ object SwapResponses { override def toString: String = s"swap $swapId canceled by user." } - case class PeerCanceled(swapId: String) extends Fail { - override def toString: String = s"swap $swapId canceled by peer." + case class PeerCanceled(swapId: String, reason: String) extends Fail { + override def toString: String = s"swap $swapId canceled by peer, reason: $reason." } case class CreateFailed(swapId: String, reason: String) extends Fail { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala index 5a4a8ed576..7a17630213 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala @@ -32,6 +32,7 @@ import fr.acinq.eclair.swap.SwapCommands._ import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapHelpers._ import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} +import fr.acinq.eclair.swap.SwapRole.Taker import fr.acinq.eclair.swap.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions.SwapClaimByCoopTx import fr.acinq.eclair.wire.protocol._ @@ -114,7 +115,7 @@ object SwapTaker { case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped } - case RestoreSwapTaker(d) => + case RestoreSwap(d) => ShortChannelId.fromCoordinates(d.request.scid) match { case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) .awaitOpeningTxConfirmed(d.request, d.agreement, d.openingTxBroadcasted, d.isInitiator) @@ -158,7 +159,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, case SwapMessageReceived(agreement: SwapOutAgreement) if agreement.protocolVersion != protocolVersion => swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) case SwapMessageReceived(agreement: SwapOutAgreement) => validateFeeInvoice(request, agreement) - case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitAgreement")) case ForwardFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap request to peer.")) case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) @@ -195,7 +196,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, case PaymentEventReceived(p: PaymentFailed) => swapCanceled(CreateFailed(request.swapId, s"Lightning payment failed: $p")) case PaymentEventReceived(p: PaymentEvent) => swapCanceled(CreateFailed(request.swapId, s"Lightning payment failed, invalid PaymentEvent received: $p.")) case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) - case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => swapCanceled(CreateFailed(request.swapId, s"Invalid message received during payOpeningTxFeeInvoice: $m")) case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during payFeeInvoice")) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) @@ -220,7 +221,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = false) - case SwapMessageReceived(_: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during sendAgreement: $m") case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during sendAgreement")) case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) @@ -237,7 +238,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { case OpeningTxConfirmed(opening) => validateOpeningTx(request, agreement, openingTxBroadcasted, opening.tx, isInitiator) - case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId)) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during awaitOpeningTxConfirmed: $m") case InvoiceExpired => sendCoopClose(request, "Timeout waiting for opening tx to confirm.") case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) @@ -261,6 +262,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, receiveSwapMessage[ValidateTxMessages](context, "validateOpeningTx") { case ValidInvoice(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => + nodeParams.db.swaps.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator)) payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, openingTx, isInitiator) case ValidInvoice(_) => sendCoopClose(request,s"Invalid opening tx: $openingTx", Some(openingTxBroadcasted)) case InvalidInvoice(reason) => sendCoopClose(request, reason, Some(openingTxBroadcasted)) @@ -340,7 +342,8 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, } def swapCanceled(failure: Fail): Behavior[SwapCommand] = { - context.system.eventStream ! Publish(Canceled(failure.swapId)) + val swapEvent = Canceled(failure.swapId, failure.toString) + context.system.eventStream ! Publish(swapEvent) failure match { case e: Error => context.log.error(s"canceled swap: $e") case s: CreateFailed => sendShortId(register, shortChannelId)(CancelSwap(s.swapId, s.toString)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index c3e9c5184d..42a334ea75 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -34,6 +34,7 @@ sealed trait TestDatabases extends Databases { override def peers: PeersDb = db.peers override def payments: PaymentsDb = db.payments override def pendingCommands: PendingCommandsDb = db.pendingCommands + override def swaps: SwapsDb = db.swaps def close(): Unit // @formatter:on } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/SwapsDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/SwapsDbSpec.scala new file mode 100644 index 0000000000..3b0eacf604 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/SwapsDbSpec.scala @@ -0,0 +1,129 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db + + +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong} +import fr.acinq.eclair.TestDatabases.{TestPgDatabases, TestSqliteDatabases} +import fr.acinq.eclair.db.pg.PgSwapsDb +import fr.acinq.eclair.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.payment.PaymentReceived.PartialPayment +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.swap.SwapEvents.ClaimByInvoicePaid +import fr.acinq.eclair.swap.SwapRole.{Maker, SwapRole, Taker} +import fr.acinq.eclair.swap.{SwapData, SwapKeyManager} +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{CltvExpiryDelta, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import org.scalatest.funsuite.AnyFunSuite + +import java.util.concurrent.Executors +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, ExecutionContextExecutor, Future} + +class SwapsDbSpec extends AnyFunSuite { + + import fr.acinq.eclair.TestDatabases.forAllDbs + + val protocolVersion = 2 + val noAsset = "" + val network: String = TestConstants.Alice.nodeParams.chainHash.toString() + val amount: Satoshi = 1000 sat + val fee: Satoshi = 100 sat + val makerKeyManager: SwapKeyManager = TestConstants.Alice.nodeParams.swapKeyManager + val takerKeyManager: SwapKeyManager = TestConstants.Bob.nodeParams.swapKeyManager + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val premium = 10 + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val paymentPreimage: ByteVector32 = ByteVector32.One + val feePreimage: ByteVector32 = ByteVector32.Zeroes + val scid = "1x1x1" + + def paymentInvoice(swapId: String): Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey(swapId), Left("SwapOutSender payment invoice"), CltvExpiryDelta(18)) + def feeInvoice(swapId: String): Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(feePreimage), makerPrivkey(swapId), Left("SwapOutSender fee invoice"), CltvExpiryDelta(18)) + def makerPrivkey(swapId: String): PrivateKey = makerKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def takerPrivkey(swapId: String): PrivateKey = takerKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def makerPubkey(swapId: String): PublicKey = makerPrivkey(swapId).publicKey + def takerPubkey(swapId: String): PublicKey = takerPrivkey(swapId).publicKey + def swapInRequest(swapId: String): SwapInRequest = SwapInRequest(protocolVersion = protocolVersion, swapId = swapId, asset = noAsset, network = network, scid = scid, amount = amount.toLong, pubkey = makerPubkey(swapId).toHex) + def swapOutRequest(swapId: String): SwapOutRequest = SwapOutRequest(protocolVersion = protocolVersion, swapId = swapId, asset = noAsset, network = network, scid = scid, amount = amount.toLong, pubkey = takerPubkey(swapId).toHex) + def swapInAgreement(swapId: String): SwapInAgreement = SwapInAgreement(protocolVersion, swapId, takerPubkey(swapId).toHex, premium) + def swapOutAgreement(swapId: String): SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId, makerPubkey(swapId).toHex, feeInvoice(swapId).toString) + def openingTxBroadcasted(swapId: String): OpeningTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice(swapId).toString, txid, scriptOut, blindingKey) + def paymentCompleteResult(swapId: String): ClaimByInvoicePaid = ClaimByInvoicePaid(swapId, PaymentReceived(paymentInvoice(swapId).paymentHash, Seq(PartialPayment(amount.toMilliSatoshi, randomBytes32())))) + def swapData(swapId: String, isInitiator: Boolean, swapType: SwapRole): SwapData = { + val (request, agreement) = (isInitiator, swapType == Maker) match { + case (true, true) => (swapInRequest(swapId), swapInAgreement(swapId)) + case (false, false) => (swapInRequest(swapId), swapInAgreement(swapId)) + case (true, false) => (swapOutRequest(swapId), swapOutAgreement(swapId)) + case (false, true) => (swapOutRequest(swapId), swapOutAgreement(swapId)) + } + SwapData(request, agreement, paymentInvoice(swapId), openingTxBroadcasted(swapId), swapType, isInitiator) + } + + test("init database two times in a row") { + forAllDbs { + case sqlite: TestSqliteDatabases => + new SqliteSwapsDb(sqlite.connection) + new SqliteSwapsDb(sqlite.connection) + case pg: TestPgDatabases => + new PgSwapsDb()(pg.datasource) + new PgSwapsDb()(pg.datasource) + } + } + + test("add/list/addResult/restore/remove swaps") { + forAllDbs { dbs => + val db = dbs.swaps + + val swap_1 = swapData(randomBytes32().toString(),isInitiator = true, Maker) + val swap_2 = swapData(randomBytes32().toString(),isInitiator = false, Maker) + val swap_3 = swapData(randomBytes32().toString(),isInitiator = true, Taker) + val swap_4 = swapData(randomBytes32().toString(),isInitiator = false, Taker) + + assert(db.list().toSet == Set.empty) + db.add(swap_1) + assert(db.list().toSet == Set(swap_1)) + db.add(swap_1) // duplicate is ignored + assert(db.list().size == 1) + db.add(swap_2) + db.add(swap_3) + db.add(swap_4) + assert(db.list().toSet == Set(swap_1, swap_2, swap_3, swap_4)) + db.addResult(paymentCompleteResult(swap_2.request.swapId)) + assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) + db.remove(swap_2.request.swapId) + assert(db.list().toSet == Set(swap_1, swap_3, swap_4)) + assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) + } + } + + test("concurrent swap updates") { + forAllDbs { dbs => + val db = dbs.swaps + implicit val ec: ExecutionContextExecutor = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(8)) + val futures = for (_ <- 0 until 2500) yield { + Future(db.add(swapData(randomBytes32().toString(),isInitiator = true, Maker))) + } + val res = Future.sequence(futures) + Await.result(res, 60 seconds) + } + } + +} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 73ff3635f5..8e3b1a7f6a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -29,8 +29,7 @@ import fr.acinq.eclair.router.Router import fr.acinq.eclair.swap.{LocalSwapKeyManager, SwapRegister} import fr.acinq.eclair.wire.protocol.IPAddress import fr.acinq.eclair.{BlockHeight, MilliSatoshi, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases, TestFeeEstimator} -import org.scalatest.concurrent.PatienceConfiguration -import org.scalatest.concurrent.{Eventually, IntegrationPatience} +import org.scalatest.concurrent.{Eventually, IntegrationPatience, PatienceConfiguration} import org.scalatest.{Assertions, EitherValues} import java.net.InetAddress @@ -92,7 +91,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat val channelFactory = Peer.SimpleChannelFactory(nodeParams, watcherTyped, relayer, wallet, txPublisherFactory) val paymentFactory = PaymentInitiator.SimplePaymentFactory(nodeParams, router, register) val paymentInitiator = system.actorOf(PaymentInitiator.props(nodeParams, paymentFactory), "payment-initiator") - val swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcherTyped, register, wallet)).onFailure(SupervisorStrategy.stop), "swap-register") + val swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcherTyped, register, wallet, Set())).onFailure(SupervisorStrategy.stop), "swap-register") val peerFactory = Switchboard.SimplePeerFactory(nodeParams, wallet, channelFactory, swapRegister) val switchboard = system.actorOf(Switchboard.props(nodeParams, peerFactory), "switchboard") readyListener.expectMsgAllOf( diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala index 567c74b00d..d605b031fc 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala @@ -34,7 +34,6 @@ import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} @@ -105,9 +104,9 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // restore the SwapInReceiver actor state from a confirmed on-chain opening tx val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) - val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = false) - swapInReceiver ! RestoreSwapTaker(swapData) - monitor.expectMessageType[RestoreSwapTaker] + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false) + swapInReceiver ! RestoreSwap(swapData) + monitor.expectMessageType[RestoreSwap] // SwapInReceiver reports status of awaiting opening transaction swapInReceiver ! GetStatus(userCli.ref) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala index d9d6b3df97..beaa1f5f20 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala @@ -33,7 +33,6 @@ import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapData.SwapData import fr.acinq.eclair.swap.SwapEvents._ import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec @@ -102,8 +101,8 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // restore the SwapInSender actor state from a confirmed on-chain opening tx val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) - val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) - swapInSender ! RestoreSwapMaker(swapData) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true) + swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] @@ -173,8 +172,8 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // restore the SwapInSender actor state from a confirmed on-chain opening tx val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) - val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) - swapInSender ! RestoreSwapMaker(swapData) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true) + swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] @@ -210,8 +209,8 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice with short expiry"), CltvExpiryDelta(18), expirySeconds = Some(2)) val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) - val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, isInitiator = true) - swapInSender ! RestoreSwapMaker(swapData) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true) + swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala index f30980b4de..24ae6e9380 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala @@ -99,78 +99,78 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } - case class FixtureParam(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + case class FixtureParam(swapOutSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) test("happy path for new swap out") { f => import f._ - // start new SwapInReceiver - swapInReceiver ! StartSwapOutSender(amount, swapId, shortChannelId) + // start new SwapOutSender + swapOutSender ! StartSwapOutSender(amount, swapId, shortChannelId) monitor.expectMessageType[StartSwapOutSender] - // SwapInReceiver: SwapOutRequest -> SwapInSender + // SwapOutSender: SwapOutRequest -> SwapOutReceiver val request = register.expectMessageType[ForwardShortId[SwapOutRequest]].message assert(request.pubkey == takerPubkey.toHex) - // SwapInSender: SwapOutAgreement -> SwapInReceiver (request fee) - swapInReceiver ! SwapMessageReceived(SwapOutAgreement(request.protocolVersion, request.swapId, makerPubkey.toString(), feeInvoice.toString)) + // SwapOutReceiver: SwapOutAgreement -> SwapOutSender (request fee) + swapOutSender ! SwapMessageReceived(SwapOutAgreement(request.protocolVersion, request.swapId, makerPubkey.toString(), feeInvoice.toString)) monitor.expectMessageType[SwapMessageReceived] - // SwapInReceiver validates fee invoice before paying the invoice + // SwapOutSender validates fee invoice before paying the invoice assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(feeInvoice.amount_opt.get, feeInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) - swapInReceiver ! GetStatus(userCli.ref) + swapOutSender ! GetStatus(userCli.ref) monitor.expectMessageType[GetStatus] assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") - // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + // wait for SwapOutSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() - // SwapInReceiver confirms the fee invoice has been paid + // SwapOutSender confirms the fee invoice has been paid testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), feeInvoice.paymentHash, feePreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), fee.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) val feePaymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent assert(feePaymentEvent.isInstanceOf[PaymentSent] && feePaymentEvent.paymentHash === feeInvoice.paymentHash) - // SwapInReceiver reports status of awaiting opening transaction after paying claim invoice - swapInReceiver ! GetStatus(userCli.ref) + // SwapOutSender reports status of awaiting opening transaction after paying claim invoice + swapOutSender ! GetStatus(userCli.ref) monitor.expectMessageType[GetStatus] assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") - // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver + // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) - swapInReceiver ! SwapMessageReceived(openingTxBroadcasted) + swapOutSender ! SwapMessageReceived(openingTxBroadcasted) monitor.expectMessageType[SwapMessageReceived] - // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + // ZmqWatcher -> SwapOutSender, trigger confirmation of opening transaction val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(request.amount.sat, makerPubkey, takerPubkey, paymentInvoice.paymentHash)), 0) - swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + swapOutSender ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) monitor.expectMessageType[OpeningTxConfirmed] - // SwapInReceiver validates invoice and opening transaction before paying the invoice + // SwapOutSender validates invoice and opening transaction before paying the invoice monitor.expectMessageType[ValidInvoice] assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(paymentInvoice.amount_opt.get, paymentInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) - // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + // wait for SwapOutSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() - // SwapInReceiver ignores payments that do not correspond to the invoice from SwapInSender + // SwapOutSender ignores payments that do not correspond to the invoice from SwapOutReceiver testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), ByteVector32.Zeroes, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) monitor.expectMessageType[PaymentEventReceived].paymentEvent monitor.expectNoMessage() - // SwapInReceiver commits a claim-by-invoice transaction after successfully paying the invoice from SwapInSender + // SwapOutSender successfully pays the invoice from SwapOutReceiver and then commits a claim-by-invoice transaction testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), paymentInvoice.paymentHash, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) val paymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent assert(paymentEvent.isInstanceOf[PaymentSent] && paymentEvent.paymentHash === paymentInvoice.paymentHash) monitor.expectMessage(ClaimTxCommitted) - // SwapInReceiver reports a successful claim by invoice + // SwapOutSender reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] val claimByInvoiceTx = makeSwapClaimByInvoiceTx(request.amount.sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) - swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + swapOutSender ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) monitor.expectMessageType[ClaimTxConfirmed] swapEvents.expectMessageType[ClaimByInvoiceConfirmed] val deathWatcher = testKit.createTestProbe[Any]() - deathWatcher.expectTerminated(swapInReceiver) + deathWatcher.expectTerminated(swapOutSender) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala index f61f77a356..319a295a44 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala @@ -23,27 +23,28 @@ import akka.actor.typed.scaladsl.adapter._ import akka.util.Timeout import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchTxConfirmed, WatchTxConfirmedTriggered} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId -import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} -import fr.acinq.eclair.swap.SwapData.SwapData -import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived, PaymentSent} +import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, ClaimByInvoicePaid, SwapEvent, TransactionPublished} import fr.acinq.eclair.swap.SwapRegister.{MessageReceived, SwapInRequested, SwapTerminated} import fr.acinq.eclair.swap.SwapResponses.{Response, SwapOpened} +import fr.acinq.eclair.swap.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} -import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion} import org.mockito.scalatest.IdiomaticMockito import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, Outcome, ParallelTestExecution} import scodec.bits.HexStringSyntax +import java.util.UUID import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} @@ -53,7 +54,9 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val noAsset = "" val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) val amount: Satoshi = 1000 sat - val swapId: String = ByteVector32.Zeroes.toHex + val fee: Satoshi = 22 sat + val swapId0: String = ByteVector32.Zeroes.toHex + val swapId1: String = ByteVector32.One.toHex val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId @@ -63,10 +66,16 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val blindingKey = "" val txId: String = ByteVector32.One.toHex - val alicePrivkey: PrivateKey = PrivateKey(randomBytes32()) + val aliceNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId + val alicePrivkey: PrivateKey = TestConstants.Alice.nodeParams.swapKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId0)).privateKey val alicePubkey: PublicKey = alicePrivkey.publicKey - val bobPubkey: PublicKey = PrivateKey(randomBytes32()).publicKey - val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, alicePrivkey, Left("PeerSwap invoice"), CltvExpiryDelta(18)) + val bobPrivkey: PrivateKey = TestConstants.Alice.nodeParams.swapKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId1)).privateKey + val bobPubkey: PublicKey = bobPrivkey.publicKey + val paymentPreimage0: ByteVector32 = ByteVector32.Zeroes + val paymentPreimage1: ByteVector32 = ByteVector32.One + val invoice0: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage0), alicePrivkey, Left("PeerSwap payment invoice0"), CltvExpiryDelta(18)) + val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(paymentPreimage1), alicePrivkey, Left("PeerSwap fee invoice"), CltvExpiryDelta(18)) + val invoice1: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage1), alicePrivkey, Left("PeerSwap payment invoice1"), CltvExpiryDelta(18)) val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) override def withFixture(test: OneArgTest): Outcome = { @@ -78,7 +87,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val wallet = new DummyOnChainWallet() { override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(6930 sat, 0 sat)) } - val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val watcher = testKit.createTestProbe[Any]() // subscribe to notification events from SwapInSender when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) @@ -86,30 +95,56 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app withFixture(test.toNoArgTest(FixtureParam(userCli, swapEvents, register, monitor, paymentHandler, wallet, watcher))) } - case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], monitor: TestProbe[SwapRegister.Command], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[ZmqWatcher.Command]) + case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], monitor: TestProbe[SwapRegister.Command], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[Any]) test("restore the swap register from the database") { f => import f._ - val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey.toString()) - val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId, bobPubkey.toString(), premium) - val openingTxBroadcasted: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txId, scriptOut, blindingKey) - val savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice, openingTxBroadcasted, isInitiator = true)) + val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId0, noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey.toString()) + val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId0, bobPubkey.toString(), premium) + val swapOutRequest: SwapOutRequest = SwapOutRequest(protocolVersion, swapId1, noAsset, network, shortChannelId.toString, amount.toLong, bobPubkey.toString()) + val swapOutAgreement: SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId1, alicePubkey.toString(), feeInvoice.toString) + val openingTxBroadcasted0: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId0, invoice0.toString, txId, scriptOut, blindingKey) + val openingTxBroadcasted1: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId1, invoice1.toString, txId, scriptOut, blindingKey) + val savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice0, openingTxBroadcasted0, swapRole = SwapRole.Maker, isInitiator = true), + SwapData(swapOutRequest, swapOutAgreement, invoice1, openingTxBroadcasted1, swapRole = SwapRole.Taker, isInitiator = true)) val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, savedData)), "SwapRegister") - // wait for SwapInSender to subscribe to PaymentEventReceived messages + // wait for SwapMaker and SwapTaker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() - // Bob: payment(paymentHash) -> Alice - val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get.paymentHash - val paymentReceived = PaymentReceived(paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) - testKit.system.eventStream ! Publish(paymentReceived) + // Taker: payment(paymentHash) -> Maker + val paymentHash0 = Bolt11Invoice.fromString(openingTxBroadcasted0.payreq).get.paymentHash + val paymentReceived0 = PaymentReceived(paymentHash0, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived0) - // SwapRegister received notice that SwapInSender completed - assert(swapEvents.expectMessageType[ClaimByInvoicePaid].swapId === swapId) + // SwapRegister received notice that SwapInSender swap completed + val swap0Completed = swapEvents.expectMessageType[ClaimByInvoicePaid] + assert(swap0Completed.swapId === swapId0) - // SwapRegister receives notification that the swap actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapId === swapId) + // SwapRegister receives notification that the swap Maker actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId0) + + // ZmqWatcher -> Taker, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(swapOutRequest.amount.sat, alicePubkey, bobPubkey, invoice1.paymentHash)), 0) + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx) + + // wait for Taker to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // Taker validates the invoice and opening transaction before paying the invoice + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice1.paymentHash, paymentPreimage1, amount.toMilliSatoshi, aliceNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + + // ZmqWatcher -> Taker, trigger confirmation of claim-by-invoice transaction + val claimByInvoiceTx = makeSwapClaimByInvoiceTx(swapOutRequest.amount.sat, bobPubkey, alicePrivkey, paymentPreimage1, feeRatePerKw, openingTx.hash, 0) + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx) + + // SwapRegister received notice that SwapOutSender completed + swapEvents.expectMessageType[TransactionPublished] + assert(swapEvents.expectMessageType[ClaimByInvoiceConfirmed].swapId === swapId1) + + // SwapRegister receives notification that the swap Taker actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId1) testKit.stop(swapRegister) } @@ -118,7 +153,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app import f._ // initialize SwapRegister - val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "SwapRegister") + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, Set())), "SwapRegister") swapEvents.expectNoMessage() userCli.expectNoMessage() From 32c2a966d72cb5712f2423b00df594021c6ec4e6 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 20 Oct 2022 20:22:58 +0200 Subject: [PATCH 18/23] Modify to work with PeerSwap as a plugin --- .../main/scala/fr/acinq/eclair/Eclair.scala | 22 ----- .../scala/fr/acinq/eclair/NodeParams.scala | 9 +-- .../main/scala/fr/acinq/eclair/Setup.scala | 17 ++-- .../fr/acinq/eclair/channel/fsm/Channel.scala | 4 +- .../scala/fr/acinq/eclair/db/Databases.scala | 5 -- .../fr/acinq/eclair/db/DualDatabases.scala | 34 -------- .../main/scala/fr/acinq/eclair/io/Peer.scala | 13 +-- .../fr/acinq/eclair/io/Switchboard.scala | 7 +- .../eclair/transactions/Transactions.scala | 12 +-- .../protocol/LightningMessageCodecs.scala | 10 +-- .../wire/protocol/LightningMessageTypes.scala | 47 ----------- .../fr/acinq/eclair/EclairImplSpec.scala | 4 +- .../scala/fr/acinq/eclair/StartupSpec.scala | 4 +- .../scala/fr/acinq/eclair/TestConstants.scala | 5 -- .../scala/fr/acinq/eclair/TestDatabases.scala | 1 - .../LocalChannelKeyManagerSpec.scala | 2 +- .../keymanager/LocalNodeKeyManagerSpec.scala | 2 +- .../basic/fixtures/MinimalNodeFixture.scala | 11 +-- .../scala/fr/acinq/eclair/io/PeerSpec.scala | 80 +++++++------------ .../scala/fr/acinq/eclair/api/Service.scala | 6 +- .../api/directives/ExtraDirectives.scala | 4 +- .../acinq/eclair/api/handlers/PeerSwap.scala | 53 ------------ 22 files changed, 63 insertions(+), 289 deletions(-) delete mode 100644 eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 0a59c16ea0..90ed250800 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -45,8 +45,6 @@ import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.PreimageReceived import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.router.Router import fr.acinq.eclair.router.Router._ -import fr.acinq.eclair.swap.SwapRegister -import fr.acinq.eclair.swap.SwapResponses.{Response, Status} import fr.acinq.eclair.wire.protocol.MessageOnionCodecs.blindedRouteCodec import fr.acinq.eclair.wire.protocol._ import grizzled.slf4j.Logging @@ -165,14 +163,6 @@ trait Eclair { def sendOnionMessage(intermediateNodes: Seq[PublicKey], destination: Either[PublicKey, Sphinx.RouteBlinding.BlindedRoute], replyPath: Option[Seq[PublicKey]], userCustomContent: ByteVector)(implicit timeout: Timeout): Future[SendOnionMessageResponse] def stop(): Future[Unit] - - def swapIn(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] - - def swapOut(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] - - def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] - - def cancelSwap(swapId: String)(implicit timeout: Timeout): Future[Response] } class EclairImpl(appKit: Kit) extends Eclair with Logging { @@ -590,16 +580,4 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { sys.exit(0) Future.successful(()) } - - override def swapIn(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = - appKit.swapRegister.ask(ref => SwapRegister.SwapInRequested(ref, amount, shortChannelId))(timeout, appKit.system.scheduler.toTyped) - - override def swapOut(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = - appKit.swapRegister.ask(ref => SwapRegister.SwapOutRequested(ref, amount, shortChannelId))(timeout, appKit.system.scheduler.toTyped) - - override def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] = - appKit.swapRegister.ask(ref => SwapRegister.ListPendingSwaps(ref))(timeout, appKit.system.scheduler.toTyped) - - override def cancelSwap(swapId: String)(implicit timeout: Timeout): Future[Response] = - appKit.swapRegister.ask(ref => SwapRegister.CancelSwapRequested(ref, swapId))(timeout, appKit.system.scheduler.toTyped) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 71662eef79..e97bac0a8e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -35,7 +35,6 @@ import fr.acinq.eclair.router.Announcements.AddressException import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios} import fr.acinq.eclair.router.PathFindingExperimentConf import fr.acinq.eclair.router.Router.{MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries} -import fr.acinq.eclair.swap.SwapKeyManager import fr.acinq.eclair.tor.Socks5ProxyParams import fr.acinq.eclair.wire.protocol._ import grizzled.slf4j.Logging @@ -55,7 +54,6 @@ import scala.jdk.CollectionConverters._ */ case class NodeParams(nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, - swapKeyManager: SwapKeyManager, instanceId: UUID, // a unique instance ID regenerated after each restart private val blockHeight: AtomicLong, alias: String, @@ -142,7 +140,6 @@ object NodeParams extends Logging { val oldSeedPath = new File(datadir, "seed.dat") val nodeSeedFilename: String = "node_seed.dat" val channelSeedFilename: String = "channel_seed.dat" - val swapSeedFilename: String = "swap_seed.dat" def getSeed(filename: String): ByteVector = { val seedPath = new File(datadir, filename) @@ -160,8 +157,7 @@ object NodeParams extends Logging { val nodeSeed = getSeed(nodeSeedFilename) val channelSeed = getSeed(channelSeedFilename) - val swapSeed = getSeed(swapSeedFilename) - Seeds(nodeSeed, channelSeed, swapSeed) + Seeds(nodeSeed, channelSeed) } private val chain2Hash: Map[String, ByteVector32] = Map( @@ -192,7 +188,7 @@ object NodeParams extends Logging { } } - def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, swapKeyManager: SwapKeyManager, + def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, torAddress_opt: Option[NodeAddress], database: Databases, blockHeight: AtomicLong, feeEstimator: FeeEstimator, pluginParams: Seq[PluginParams] = Nil): NodeParams = { // check configuration for keys that have been renamed @@ -426,7 +422,6 @@ object NodeParams extends Logging { NodeParams( nodeKeyManager = nodeKeyManager, channelKeyManager = channelKeyManager, - swapKeyManager = swapKeyManager, instanceId = instanceId, blockHeight = blockHeight, alias = nodeAlias, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index 64dd566a26..d9c3d8bfa6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -43,7 +43,6 @@ import fr.acinq.eclair.payment.receive.PaymentHandler import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator} import fr.acinq.eclair.router._ -import fr.acinq.eclair.swap.{LocalSwapKeyManager, SwapRegister} import fr.acinq.eclair.tor.{Controller, TorProtocolHandler} import fr.acinq.eclair.wire.protocol.NodeAddress import grizzled.slf4j.Logging @@ -94,13 +93,12 @@ class Setup(val datadir: File, datadir.mkdirs() val config = system.settings.config.getConfig("eclair") - val Seeds(nodeSeed, channelSeed, swapSeed) = seeds_opt.getOrElse(NodeParams.getSeeds(datadir)) + val Seeds(nodeSeed, channelSeed) = seeds_opt.getOrElse(NodeParams.getSeeds(datadir)) val chain = config.getString("chain") val chaindir = new File(datadir, chain) chaindir.mkdirs() val nodeKeyManager = new LocalNodeKeyManager(nodeSeed, NodeParams.hashFromChain(chain)) val channelKeyManager = new LocalChannelKeyManager(channelSeed, NodeParams.hashFromChain(chain)) - val swapKeyManager = new LocalSwapKeyManager(swapSeed, NodeParams.hashFromChain(chain)) val instanceId = UUID.randomUUID() logger.info(s"instanceid=$instanceId") @@ -134,7 +132,7 @@ class Setup(val datadir: File, // @formatter:on } - val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, swapKeyManager, initTor(), databases, blockHeight, feeEstimator, pluginParams) + val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, initTor(), databases, blockHeight, feeEstimator, pluginParams) pluginParams.foreach(param => logger.info(s"using plugin=${param.name}")) val serverBindingAddress = new InetSocketAddress(config.getString("server.binding-ip"), config.getInt("server.port")) @@ -306,8 +304,7 @@ class Setup(val datadir: File, txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, watcher, bitcoinClient) channelFactory = Peer.SimpleChannelFactory(nodeParams, watcher, relayer, bitcoinClient, txPublisherFactory) paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, PaymentInitiator.SimplePaymentFactory(nodeParams, router, register)), "payment-initiator", SupervisorStrategy.Restart)) - swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcher, register, bitcoinClient, nodeParams.db.swaps.restore().toSet)).onFailure(typed.SupervisorStrategy.resume), "swap-register") - peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory, swapRegister) + peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory) switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, peerFactory), "switchboard", SupervisorStrategy.Resume)) clientSpawner = system.actorOf(SimpleSupervisor.props(ClientSpawner.props(nodeParams.keyPair, nodeParams.socksProxy_opt, nodeParams.peerConnectionConf, switchboard, router), "client-spawner", SupervisorStrategy.Restart)) @@ -332,8 +329,7 @@ class Setup(val datadir: File, channelsListener = channelsListener, balanceActor = balanceActor, postman = postman, - wallet = bitcoinClient, - swapRegister = swapRegister) + wallet = bitcoinClient) zmqBlockTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) zmqTxTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) @@ -384,7 +380,7 @@ class Setup(val datadir: File, object Setup { - final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector, swapSeed: ByteVector) + final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector) } @@ -401,8 +397,7 @@ case class Kit(nodeParams: NodeParams, channelsListener: typed.ActorRef[ChannelsListener.Command], balanceActor: typed.ActorRef[BalanceActor.Command], postman: typed.ActorRef[Postman.Command], - wallet: OnChainWallet, - swapRegister: typed.ActorRef[SwapRegister.Command]) + wallet: OnChainWallet) object Kit { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 06fb6710d3..040b7f05b1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1642,7 +1642,9 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val log.warning(s"processing local commit spent in catch-all handler") spendLocalCurrent(d) - case Event(msg: HasSwapId, _) => send(msg) + // forward unknown messages that originate from loaded plugins + case Event(unknownMsg: UnknownMessage, _) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => + send(unknownMsg) stay() } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index def99e9735..9713cfbf1b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -44,7 +44,6 @@ trait Databases { def peers: PeersDb def payments: PaymentsDb def pendingCommands: PendingCommandsDb - def swaps: SwapsDb //@formatter:on } @@ -66,7 +65,6 @@ object Databases extends Logging { peers: SqlitePeersDb, payments: SqlitePaymentsDb, pendingCommands: SqlitePendingCommandsDb, - swaps: SqliteSwapsDb, private val backupConnection: Connection) extends Databases with FileBackup { override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) { statement => { @@ -85,7 +83,6 @@ object Databases extends Logging { peers = new SqlitePeersDb(eclairJdbc), payments = new SqlitePaymentsDb(eclairJdbc), pendingCommands = new SqlitePendingCommandsDb(eclairJdbc), - swaps = new SqliteSwapsDb(eclairJdbc), backupConnection = eclairJdbc ) } @@ -98,7 +95,6 @@ object Databases extends Logging { payments: PgPaymentsDb, pendingCommands: PgPendingCommandsDb, dataSource: HikariDataSource, - swaps: PgSwapsDb, lock: PgLock) extends Databases with ExclusiveLock { override def obtainExclusiveLock(): Unit = lock.obtainExclusiveLock(dataSource) } @@ -158,7 +154,6 @@ object Databases extends Logging { peers = new PgPeersDb, payments = new PgPaymentsDb, pendingCommands = new PgPendingCommandsDb, - swaps = new PgSwapsDb, dataSource = ds, lock = lock) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala index 8481de32e0..f65d993be3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -9,8 +9,6 @@ import fr.acinq.eclair.db.DualDatabases.runAsync import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.router.Router -import fr.acinq.eclair.swap.SwapData -import fr.acinq.eclair.swap.SwapEvents.SwapEvent import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli} import grizzled.slf4j.Logging @@ -41,8 +39,6 @@ case class DualDatabases(primary: Databases, secondary: Databases) extends Datab override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands) - override val swaps: SwapsDb = DualSwapsDb(primary.swaps, secondary.swaps) - /** if one of the database supports file backup, we use it */ override def backup(backupFile: File): Unit = (primary, secondary) match { case (f: FileBackup, _) => f.backup(backupFile) @@ -392,33 +388,3 @@ case class DualPendingCommandsDb(primary: PendingCommandsDb, secondary: PendingC primary.listSettlementCommands() } } - -case class DualSwapsDb(primary: SwapsDb, secondary: SwapsDb) extends SwapsDb { - - private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build())) - - override def add(swapData: SwapData): Unit = { - runAsync(secondary.add(swapData)) - primary.add(swapData) - } - - override def addResult(swapEvent: SwapEvent): Unit = { - runAsync(secondary.addResult(swapEvent)) - primary.addResult(swapEvent) - } - - override def remove(swapId: String): Unit = { - runAsync(secondary.remove(swapId)) - primary.remove(swapId) - } - - override def restore(): Seq[SwapData] = { - runAsync(secondary.restore()) - primary.restore() - } - - override def list(): Seq[SwapData] = { - runAsync(secondary.list()) - primary.list() - } -} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index a4e69bdfdc..56acc623b3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -38,10 +38,8 @@ import fr.acinq.eclair.io.PeerConnection.KillReason import fr.acinq.eclair.io.Switchboard.RelayMessage import fr.acinq.eclair.message.OnionMessages import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes -import fr.acinq.eclair.swap.SwapRegister -import fr.acinq.eclair.swap.SwapRegister.MessageReceived import fr.acinq.eclair.wire.protocol -import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasSwapId, HasTemporaryChannelId, LightningMessage, NodeAddress, OnionMessage, RoutingMessage, UnknownMessage, Warning} +import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, NodeAddress, OnionMessage, RoutingMessage, UnknownMessage, Warning} import scodec.bits.ByteVector import scala.concurrent.{ExecutionContext, Future} @@ -57,7 +55,7 @@ import scala.util.{Failure, Success} * * Created by PM on 26/08/2016. */ -class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory, switchboard: ActorRef, swapRegister: typed.ActorRef[SwapRegister.Command]) extends FSMDiagnosticActorLogging[Peer.State, Peer.Data] { +class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory, switchboard: ActorRef) extends FSMDiagnosticActorLogging[Peer.State, Peer.Data] { import Peer._ @@ -300,10 +298,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA replyTo_opt.foreach(_ ! MessageRelay.Sent(messageId)) stay() - case Event(message: HasSwapId, d: ConnectedData) => - swapRegister ! MessageReceived(message) - stay() - + // TODO: plugin actors should register to receive messages with certain tags case Event(unknownMsg: UnknownMessage, d: ConnectedData) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => context.system.eventStream.publish(UnknownMessageReceived(self, remoteNodeId, unknownMsg, d.connectionInfo)) stay() @@ -494,7 +489,7 @@ object Peer { context.actorOf(Channel.props(nodeParams, wallet, remoteNodeId, watcher, relayer, txPublisherFactory, origin_opt)) } - def props(nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: ChannelFactory, switchboard: ActorRef, swapRegister: typed.ActorRef[SwapRegister.Command]): Props = Props(new Peer(nodeParams, remoteNodeId, wallet, channelFactory, switchboard, swapRegister)) + def props(nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainAddressGenerator, channelFactory: ChannelFactory, switchboard: ActorRef): Props = Props(new Peer(nodeParams, remoteNodeId, wallet, channelFactory, switchboard)) // @formatter:off diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala index d7be78ed58..7244765119 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala @@ -28,9 +28,8 @@ import fr.acinq.eclair.io.MessageRelay.RelayPolicy import fr.acinq.eclair.io.Peer.PeerInfoResponse import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes import fr.acinq.eclair.router.Router.RouterConf -import fr.acinq.eclair.swap.SwapRegister import fr.acinq.eclair.wire.protocol.OnionMessage -import fr.acinq.eclair.{SubscriptionsComplete, NodeParams} +import fr.acinq.eclair.{NodeParams, SubscriptionsComplete} /** * Ties network connections to peers. @@ -153,9 +152,9 @@ object Switchboard { def spawn(context: ActorContext, remoteNodeId: PublicKey): ActorRef } - case class SimplePeerFactory(nodeParams: NodeParams, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory, swapRegister: typed.ActorRef[SwapRegister.Command]) extends PeerFactory { + case class SimplePeerFactory(nodeParams: NodeParams, wallet: OnChainAddressGenerator, channelFactory: Peer.ChannelFactory) extends PeerFactory { override def spawn(context: ActorContext, remoteNodeId: PublicKey): ActorRef = - context.actorOf(Peer.props(nodeParams, remoteNodeId, wallet, channelFactory, context.self, swapRegister), name = peerActorName(remoteNodeId)) + context.actorOf(Peer.props(nodeParams, remoteNodeId, wallet, channelFactory, context.self), name = peerActorName(remoteNodeId)) } def props(nodeParams: NodeParams, peerFactory: PeerFactory) = Props(new Switchboard(nodeParams, peerFactory)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 70502e1fe1..3618d7184c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -16,12 +16,12 @@ package fr.acinq.eclair.transactions +import fr.acinq.bitcoin.ScriptFlags +import fr.acinq.bitcoin.SigHash._ +import fr.acinq.bitcoin.SigVersion._ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, ripemd160} import fr.acinq.bitcoin.scalacompat.Script._ import fr.acinq.bitcoin.scalacompat._ -import fr.acinq.bitcoin.SigHash._ -import fr.acinq.bitcoin.SigVersion._ -import fr.acinq.bitcoin.ScriptFlags import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.transactions.CommitmentOutput._ @@ -100,7 +100,7 @@ object Transactions { case object Remote extends TxOwner } - sealed trait TransactionWithInputInfo { + trait TransactionWithInputInfo { def input: InputInfo def desc: String def tx: Transaction @@ -163,10 +163,6 @@ object Transactions { sealed trait TxGenerationSkipped case object OutputNotFound extends TxGenerationSkipped { override def toString = "output not found (probably trimmed)" } case object AmountBelowDustLimit extends TxGenerationSkipped { override def toString = "amount is below dust limit" } - - case class SwapClaimByInvoiceTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbyinvoice-tx" } - case class SwapClaimByCoopTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycoop-tx" } - case class SwapClaimByCsvTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycsv-tx" } // @formatter:on /** diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index d3141934be..a96b83aa57 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -19,7 +19,6 @@ package fr.acinq.eclair.wire.protocol import fr.acinq.bitcoin.scalacompat.ScriptWitness import fr.acinq.eclair.wire.Monitoring.{Metrics, Tags} import fr.acinq.eclair.wire.protocol.CommonCodecs._ -import fr.acinq.eclair.wire.protocol.PeerSwapMessageCodecs._ import fr.acinq.eclair.{Feature, Features, InitFeature, KamonExt} import scodec.bits.{BitVector, ByteVector, HexStringSyntax} import scodec.codecs._ @@ -464,14 +463,7 @@ object LightningMessageCodecs { .typecase(264, replyChannelRangeCodec) .typecase(265, gossipTimestampFilterCodec) .typecase(513, onionMessageCodec) - // TODO: move PeerSwap message handling to a plugin - .typecase(42069, swapInRequestCodec) - .typecase(42071, swapOutRequestCodec) - .typecase(42073, swapInAgreementCodec) - .typecase(42075, swapOutAgreementCodec) - .typecase(42077, openingTxBroadcastedCodec) - .typecase(42079, canceledCodec) - .typecase(42081, coopCloseCodec) + // NB: blank lines to minimize merge conflicts // diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 7e8b7ca352..8cc0f4d2c8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -22,11 +22,9 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, ScriptWitness, Transaction} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.{ChannelFlags, ChannelType} -import fr.acinq.eclair.json.PeerSwapJsonSerializers import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.wire.protocol.ChannelReadyTlv.ShortChannelIdTlv import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64, isAsciiPrintable} -import org.json4s.jackson.Serialization import scodec.bits.ByteVector import java.net.{Inet4Address, Inet6Address, InetAddress} @@ -51,7 +49,6 @@ sealed trait HasTemporaryChannelId extends LightningMessage { def temporaryChann sealed trait HasChannelId extends LightningMessage { def channelId: ByteVector32 } // <- not in the spec sealed trait HasChainHash extends LightningMessage { def chainHash: ByteVector32 } // <- not in the spec sealed trait HasSerialId extends LightningMessage { def serialId: UInt64 } // <- not in the spec -sealed trait HasSwapId extends LightningMessage { def swapId: String } // <- not in the spec sealed trait UpdateMessage extends HtlcMessage // <- not in the spec sealed trait HtlcSettlementMessage extends UpdateMessage { def id: Long } // <- not in the spec // @formatter:on @@ -492,50 +489,6 @@ case class GossipTimestampFilter(chainHash: ByteVector32, firstTimestamp: Timest case class OnionMessage(blindingKey: PublicKey, onionRoutingPacket: OnionRoutingPacket, tlvStream: TlvStream[OnionMessageTlv] = TlvStream.empty) extends LightningMessage -sealed trait PeerSwapMessage extends LightningMessage - -sealed abstract class JSonBlobMessage() extends PeerSwapMessage { - def json: String = { - Serialization.write(this)(PeerSwapJsonSerializers.formats) - } -} - -sealed trait HasSwapVersion { def protocolVersion: Long} - -sealed trait SwapRequest extends JSonBlobMessage with HasSwapId with HasSwapVersion { - def asset: String - def network: String - def scid: String - def amount: Long - def pubkey: String -} - -case class SwapInRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest - -case class SwapOutRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest - -sealed trait SwapAgreement extends JSonBlobMessage with HasSwapId with HasSwapVersion { - def pubkey: String - def premium: Long - def payreq: String -} - -case class SwapInAgreement(protocolVersion: Long, swapId: String, pubkey: String, premium: Long) extends SwapAgreement { - override def payreq: String = "" -} - -case class SwapOutAgreement(protocolVersion: Long, swapId: String, pubkey: String, payreq: String) extends SwapAgreement { - override def premium: Long = 0 -} - -case class OpeningTxBroadcasted(swapId: String, payreq: String, txId: String, scriptOut: Long, blindingKey: String) extends JSonBlobMessage with HasSwapId - -case class CancelSwap(swapId: String, message: String) extends JSonBlobMessage with HasSwapId - -case class CoopClose(swapId: String, message: String, privkey: String) extends JSonBlobMessage with HasSwapId - -case class UnknownPeerSwapMessage(tag: Int, data: ByteVector) extends PeerSwapMessage - // NB: blank lines to minimize merge conflicts // diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 57d48665a8..dd34358c16 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -70,7 +70,6 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val channelsListener = TestProbe() val balanceActor = TestProbe() val postman = TestProbe() - val swapRegister = TestProbe() val kit = Kit( TestConstants.Alice.nodeParams, system, @@ -85,8 +84,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I channelsListener.ref.toTyped, balanceActor.ref.toTyped, postman.ref.toTyped, - new DummyOnChainWallet(), - swapRegister.ref.toTyped + new DummyOnChainWallet() ) withFixture(test.toNoArgTest(FixtureParam(register, relayer, router, paymentInitiator, switchboard, paymentHandler, TestProbe(), kit))) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index c366de148e..8e5a25e74f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -23,7 +23,6 @@ import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features._ import fr.acinq.eclair.blockchain.fee.{DustTolerance, FeeratePerByte, FeeratePerKw, FeerateTolerance} import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager} -import fr.acinq.eclair.swap.LocalSwapKeyManager import org.scalatest.funsuite.AnyFunSuite import scodec.bits.{ByteVector, HexStringSyntax} @@ -40,10 +39,9 @@ class StartupSpec extends AnyFunSuite { val blockCount = new AtomicLong(0) val nodeKeyManager = new LocalNodeKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) - val swapKeyManager = new LocalSwapKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val feeEstimator = new TestFeeEstimator() val db = TestDatabases.inMemoryDb() - NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, swapKeyManager, None, db, blockCount, feeEstimator) + NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, db, blockCount, feeEstimator) } test("check configuration") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index a5bc68b32b..1b027c2137 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -30,7 +30,6 @@ import fr.acinq.eclair.payment.relay.Relayer.{RelayFees, RelayParams} import fr.acinq.eclair.router.Graph.WeightRatios import fr.acinq.eclair.router.PathFindingExperimentConf import fr.acinq.eclair.router.Router.{MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries} -import fr.acinq.eclair.swap.LocalSwapKeyManager import fr.acinq.eclair.wire.protocol.{Color, EncodingType, NodeAddress, OnionRoutingPacket} import org.scalatest.Tag import scodec.bits.{ByteVector, HexStringSyntax} @@ -76,13 +75,11 @@ object TestConstants { val seed: ByteVector32 = ByteVector32(hex"b4acd47335b25ab7b84b8c020997b12018592bb4631b868762154d77fa8b93a3") // 02aaaa... val nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash) - val swapKeyManager = new LocalSwapKeyManager(seed, Block.RegtestGenesisBlock.hash) // This is a function, and not a val! When called will return a new NodeParams def nodeParams: NodeParams = NodeParams( nodeKeyManager, channelKeyManager, - swapKeyManager, blockHeight = new AtomicLong(defaultBlockHeight), alias = "alice", color = Color(1, 2, 3), @@ -224,12 +221,10 @@ object TestConstants { val seed: ByteVector32 = ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492") // 02bbbb... val nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash) - val swapKeyManager = new LocalSwapKeyManager(seed, Block.RegtestGenesisBlock.hash) def nodeParams: NodeParams = NodeParams( nodeKeyManager, channelKeyManager, - swapKeyManager, blockHeight = new AtomicLong(defaultBlockHeight), alias = "bob", color = Color(4, 5, 6), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index 42a334ea75..c3e9c5184d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -34,7 +34,6 @@ sealed trait TestDatabases extends Databases { override def peers: PeersDb = db.peers override def payments: PaymentsDb = db.payments override def pendingCommands: PendingCommandsDb = db.pendingCommands - override def swaps: SwapsDb = db.swaps def close(): Unit // @formatter:on } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala index ca1afffcf2..f14e81f735 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala @@ -134,7 +134,7 @@ class LocalChannelKeyManagerSpec extends AnyFunSuite { val seed = hex"17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501" val seedDatFile = TestUtils.createSeedFile("seed.dat", seed.toArray) - val Seeds(_, _, _) = NodeParams.getSeeds(seedDatFile.getParentFile) + val Seeds(_, _) = NodeParams.getSeeds(seedDatFile.getParentFile) val channelSeedDatFile = new File(seedDatFile.getParentFile, "channel_seed.dat") assert(channelSeedDatFile.exists()) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala index f1051b6189..333ce9a444 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala @@ -53,7 +53,7 @@ class LocalNodeKeyManagerSpec extends AnyFunSuite { val seed = hex"17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501" val seedDatFile = TestUtils.createSeedFile("seed.dat", seed.toArray) - val Seeds(_, _, _) = NodeParams.getSeeds(seedDatFile.getParentFile) + val Seeds(_, _) = NodeParams.getSeeds(seedDatFile.getParentFile) val nodeSeedDatFile = new File(seedDatFile.getParentFile, "node_seed.dat") assert(nodeSeedDatFile.exists()) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 8e3b1a7f6a..bd686c86db 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -1,8 +1,6 @@ package fr.acinq.eclair.integration.basic.fixtures -import akka.actor.typed.SupervisorStrategy -import akka.actor.typed.scaladsl.Behaviors -import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps, TypedActorRefOps} +import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps import akka.actor.{ActorRef, ActorSystem} import akka.testkit.{TestActor, TestProbe} import com.softwaremill.quicklens.ModifyPimp @@ -26,7 +24,6 @@ import fr.acinq.eclair.payment.receive.{MultiPartHandler, PaymentHandler} import fr.acinq.eclair.payment.relay.{ChannelRelayer, Relayer} import fr.acinq.eclair.payment.send.PaymentInitiator import fr.acinq.eclair.router.Router -import fr.acinq.eclair.swap.{LocalSwapKeyManager, SwapRegister} import fr.acinq.eclair.wire.protocol.IPAddress import fr.acinq.eclair.{BlockHeight, MilliSatoshi, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases, TestFeeEstimator} import org.scalatest.concurrent.{Eventually, IntegrationPatience, PatienceConfiguration} @@ -51,7 +48,6 @@ case class MinimalNodeFixture private(nodeParams: NodeParams, switchboard: ActorRef, paymentInitiator: ActorRef, paymentHandler: ActorRef, - swapRegister: ActorRef, watcher: TestProbe, wallet: DummyOnChainWallet, bitcoinClient: TestBitcoinCoreClient) @@ -64,7 +60,6 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat instanceId = UUID.randomUUID(), nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash), channelKeyManager = new LocalChannelKeyManager(seed, Block.RegtestGenesisBlock.hash), - swapKeyManager = new LocalSwapKeyManager(seed, Block.RegtestGenesisBlock.hash), torAddress_opt = None, database = TestDatabases.inMemoryDb(), blockHeight = new AtomicLong(400_000), @@ -91,8 +86,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat val channelFactory = Peer.SimpleChannelFactory(nodeParams, watcherTyped, relayer, wallet, txPublisherFactory) val paymentFactory = PaymentInitiator.SimplePaymentFactory(nodeParams, router, register) val paymentInitiator = system.actorOf(PaymentInitiator.props(nodeParams, paymentFactory), "payment-initiator") - val swapRegister = system.spawn(Behaviors.supervise(SwapRegister(nodeParams, paymentInitiator, watcherTyped, register, wallet, Set())).onFailure(SupervisorStrategy.stop), "swap-register") - val peerFactory = Switchboard.SimplePeerFactory(nodeParams, wallet, channelFactory, swapRegister) + val peerFactory = Switchboard.SimplePeerFactory(nodeParams, wallet, channelFactory) val switchboard = system.actorOf(Switchboard.props(nodeParams, peerFactory), "switchboard") readyListener.expectMsgAllOf( SubscriptionsComplete(classOf[Router]), @@ -108,7 +102,6 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat switchboard = switchboard, paymentInitiator = paymentInitiator, paymentHandler = paymentHandler, - swapRegister = swapRegister.toClassic, watcher = watcher, wallet = wallet, bitcoinClient = bitcoinClient diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala index e49d5fba0c..8fd1ce1c2e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala @@ -34,14 +34,12 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.ChannelStateTestsTags import fr.acinq.eclair.io.Peer._ import fr.acinq.eclair.message.OnionMessages.{Recipient, buildMessage} -import fr.acinq.eclair.swap.SwapRegister -import fr.acinq.eclair.swap.SwapRegister.MessageReceived import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol._ import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, ParallelTestExecution, Tag} -import scodec.bits.{ByteVector, HexStringSyntax} +import scodec.bits.ByteVector import java.net.InetSocketAddress import java.nio.channels.ServerSocketChannel @@ -53,7 +51,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val fakeIPAddress: NodeAddress = NodeAddress.fromParts("1.2.3.4", 42000).get - case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, switchboard: TestProbe, swapRegister: TestProbe) + case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, switchboard: TestProbe) case class FakeChannelFactory(channel: TestProbe) extends ChannelFactory { override def spawn(context: ActorContext, remoteNodeId: PublicKey, origin_opt: Option[ActorRef]): ActorRef = { @@ -68,7 +66,6 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val peerConnection = TestProbe() val channel = TestProbe() val switchboard = TestProbe() - val swapRegister = TestProbe() import com.softwaremill.quicklens._ val aliceParams = TestConstants.Alice.nodeParams @@ -86,11 +83,11 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle aliceParams.db.network.addNode(bobAnnouncement) } - val peer: TestFSMRef[Peer.State, Peer.Data, Peer] = TestFSMRef(new Peer(aliceParams, remoteNodeId, wallet, FakeChannelFactory(channel), switchboard.ref, swapRegister.ref.toTyped[SwapRegister.Command])) - withFixture(test.toNoArgTest(FixtureParam(aliceParams, remoteNodeId, peer, peerConnection, channel, switchboard, swapRegister))) + val peer: TestFSMRef[Peer.State, Peer.Data, Peer] = TestFSMRef(new Peer(aliceParams, remoteNodeId, wallet, FakeChannelFactory(channel), switchboard.ref)) + withFixture(test.toNoArgTest(FixtureParam(aliceParams, remoteNodeId, peer, peerConnection, channel, switchboard))) } - def connect(remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, switchboard: TestProbe, swapRegister: TestProbe, channels: Set[PersistentChannelData] = Set.empty, remoteInit: protocol.Init = protocol.Init(Bob.nodeParams.features.initFeatures())): Unit = { + def connect(remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, switchboard: TestProbe, channels: Set[PersistentChannelData] = Set.empty, remoteInit: protocol.Init = protocol.Init(Bob.nodeParams.features.initFeatures())): Unit = { // let's simulate a connection switchboard.send(peer, Peer.Init(channels)) val localInit = protocol.Init(peer.underlyingActor.nodeParams.features.initFeatures()) @@ -106,7 +103,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("restore existing channels") { f => import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) probe.send(peer, Peer.GetPeerInfo(None)) probe.expectMsg(PeerInfo(peer, remoteNodeId, Peer.CONNECTED, Some(fakeIPAddress), 1)) } @@ -182,7 +179,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) probe.send(peer, Peer.Connect(remoteNodeId, None, probe.ref, isPersistent = true)) probe.expectMsgType[PeerConnection.ConnectionResult.AlreadyConnected] @@ -193,7 +190,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val listener = TestProbe() system.eventStream.subscribe(listener.ref, classOf[UnknownMessageReceived]) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) peerConnection.send(peer, UnknownMessage(tag = TestConstants.pluginParams.messageTags.head, data = ByteVector.empty)) listener.expectMsgType[UnknownMessageReceived] @@ -205,7 +202,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) probe.send(peer, Peer.GetPeerInfo(Some(probe.ref.toTyped))) assert(probe.expectMsgType[Peer.PeerInfo].state == Peer.CONNECTED) @@ -236,7 +233,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val peerConnection2 = TestProbe() val peerConnection3 = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) channel.expectMsg(INPUT_RESTORED(ChannelCodecsSpec.normal)) val (localInit, remoteInit) = { val inputReconnected = channel.expectMsgType[INPUT_RECONNECTED] @@ -289,7 +286,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) + connect(remoteNodeId, peer, peerConnection, switchboard) assert(peer.stateData.channels.isEmpty) val open = createOpenChannelMessage() @@ -311,7 +308,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() val fundingAmountBig = Channel.MAX_FUNDING + 10000.sat system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) + connect(remoteNodeId, peer, peerConnection, switchboard) assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, fundingAmountBig, None, None, None, None, None)) @@ -325,7 +322,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() val fundingAmountBig = Channel.MAX_FUNDING + 10000.sat system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) // Bob doesn't support wumbo, Alice does + connect(remoteNodeId, peer, peerConnection, switchboard) // Bob doesn't support wumbo, Alice does assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, fundingAmountBig, None, None, None, None, None)) @@ -339,7 +336,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() val fundingAmountBig = Btc(1).toSatoshi system.eventStream.subscribe(probe.ref, classOf[ChannelCreated]) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(Wumbo -> Optional))) // Bob supports wumbo + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(Wumbo -> Optional))) // Bob supports wumbo assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, fundingAmountBig, None, None, None, None, None)) @@ -350,7 +347,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("don't spawn a channel if we don't support their channel type") { f => import f._ - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) + connect(remoteNodeId, peer, peerConnection, switchboard) assert(peer.stateData.channels.isEmpty) // They only support anchor outputs and we don't. @@ -383,7 +380,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val remoteInit = protocol.Init(Features(ChannelType -> Optional)) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = remoteInit) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = remoteInit) assert(peer.stateData.channels.isEmpty) val open = createOpenChannelMessage() peerConnection.send(peer, open) @@ -393,7 +390,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("don't spawn a dual funded channel if not supported") { f => import f._ - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) + connect(remoteNodeId, peer, peerConnection, switchboard) val open = createOpenDualFundedChannelMessage() peerConnection.send(peer, open) peerConnection.expectMsg(Error(open.temporaryChannelId, "dual funding is not supported")) @@ -404,7 +401,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle val probe = TestProbe() // Both peers support option_dual_fund, so it is automatically used. - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, 25000 sat, None, None, None, None, None)) assert(channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR].dualFunded) @@ -414,7 +411,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ // Both peers support option_dual_fund, so it is automatically used. - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional))) assert(peer.stateData.channels.isEmpty) val open = createOpenDualFundedChannelMessage() peerConnection.send(peer, open) @@ -427,7 +424,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ // We both support option_static_remotekey but they want to open a standard channel. - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional))) assert(peer.stateData.channels.isEmpty) val open = createOpenChannelMessage(TlvStream[OpenChannelTlv](ChannelTlv.ChannelTypeTlv(ChannelTypes.Standard))) peerConnection.send(peer, open) @@ -442,7 +439,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) assert(peer.stateData.channels.isEmpty) probe.send(peer, Peer.OpenChannel(remoteNodeId, 15000 sat, None, None, None, None, None)) @@ -461,7 +458,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional))) assert(peer.stateData.channels.isEmpty) // We ensure the current network feerate is higher than the default anchor output feerate. @@ -480,7 +477,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional))) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional))) assert(peer.stateData.channels.isEmpty) // We ensure the current network feerate is higher than the default anchor output feerate. @@ -499,7 +496,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Mandatory))) probe.send(peer, Peer.OpenChannel(remoteNodeId, 24000 sat, None, None, None, None, None)) val init = channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR] assert(init.channelType == ChannelTypes.StaticRemoteKey) @@ -526,9 +523,8 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle channel.ref } } - val swapRegister = TestProbe() - val peer = TestFSMRef(new Peer(TestConstants.Alice.nodeParams, remoteNodeId, new DummyOnChainWallet(), channelFactory, switchboard.ref, swapRegister.ref.toTyped[SwapRegister.Command])) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister) + val peer = TestFSMRef(new Peer(TestConstants.Alice.nodeParams, remoteNodeId, new DummyOnChainWallet(), channelFactory, switchboard.ref)) + connect(remoteNodeId, peer, peerConnection, switchboard) probe.send(peer, Peer.OpenChannel(remoteNodeId, 15000 sat, None, Some(100 msat), None, None, None)) val init = channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR] assert(init.fundingAmount == 15000.sat) @@ -538,7 +534,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("handle final channelId assigned in state DISCONNECTED") { f => import f._ val probe = TestProbe() - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) peer ! ConnectionDown(peerConnection.ref) probe.send(peer, Peer.GetPeerInfo(Some(probe.ref.toTyped))) val peerInfo1 = probe.expectMsgType[Peer.PeerInfo] @@ -555,7 +551,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() system.eventStream.subscribe(probe.ref, classOf[LastChannelClosed]) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) probe.send(channel.ref, PoisonPill) probe.expectMsg(LastChannelClosed(peer, remoteNodeId)) } @@ -564,7 +560,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle import f._ val probe = TestProbe() system.eventStream.subscribe(probe.ref, classOf[LastChannelClosed]) - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) peer ! ConnectionDown(peerConnection.ref) probe.send(channel.ref, PoisonPill) probe.expectMsg(LastChannelClosed(peer, remoteNodeId)) @@ -572,7 +568,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle test("reply to relay request") { f => import f._ - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) val (_, msg) = buildMessage(randomKey(), randomKey(), Nil, Recipient(remoteNodeId, None), Nil) val messageId = randomBytes32() val probe = TestProbe() @@ -588,22 +584,6 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle peer ! RelayOnionMessage(messageId, msg, Some(probe.ref.toTyped)) probe.expectMsg(MessageRelay.Disconnected(messageId)) } - - test("forward messages with a swapId defined to the SwapRegister") { f => - import f._ - connect(remoteNodeId, peer, peerConnection, switchboard, swapRegister, channels = Set(ChannelCodecsSpec.normal)) - - val protocolVersion = 2 - val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" - val premium = 10 - val responderPubkey = randomKey().publicKey - - val swapInAgreement = SwapInAgreement(protocolVersion, swapId.toHex, responderPubkey.toString, premium) - - peerConnection.send(peer, swapInAgreement) - val messageReceived = swapRegister.expectMsgType[MessageReceived] - assert(messageReceived.message === swapInAgreement) - } } object PeerSpec { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala index 9995f5621e..9a652fbbe7 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -18,12 +18,12 @@ package fr.acinq.eclair.api import akka.actor.ActorSystem import akka.http.scaladsl.server._ -import fr.acinq.eclair.{Eclair, RouteProvider} import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.api.handlers._ +import fr.acinq.eclair.{Eclair, RouteProvider} import grizzled.slf4j.Logging -trait Service extends EclairDirectives with WebSocket with Node with Channel with Fees with PathFinding with Invoice with Payment with Message with OnChain with PeerSwap with Logging { +trait Service extends EclairDirectives with WebSocket with Node with Channel with Fees with PathFinding with Invoice with Payment with Message with OnChain with Logging { /** * Allows router access to the API password as configured in eclair.conf @@ -46,7 +46,7 @@ trait Service extends EclairDirectives with WebSocket with Node with Channel wit * This is where we handle errors to ensure all routes are correctly tried before rejecting. */ def finalRoutes(extraRouteProviders: Seq[RouteProvider] = Nil): Route = securedHandler { - val baseRoutes = nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ peerSwapRoutes ~ webSocket + val baseRoutes = nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket extraRouteProviders.map(_.route(this)).foldLeft(baseRoutes)(_ ~ _) } } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala index cfaacff093..ff11b940e8 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala @@ -21,8 +21,8 @@ import akka.http.scaladsl.marshalling.ToResponseMarshaller import akka.http.scaladsl.model.StatusCodes.NotFound import akka.http.scaladsl.model.{ContentTypes, HttpResponse} import akka.http.scaladsl.server.{Directive1, Directives, MalformedFormFieldRejection, Route} +import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} import fr.acinq.eclair.ApiTypes.ChannelIdentifier import fr.acinq.eclair.api.serde.FormParamExtractors._ import fr.acinq.eclair.api.serde.JsonSupport._ @@ -47,13 +47,11 @@ trait ExtraDirectives extends Directives { val fromFormParam: NameDefaultUnmarshallerReceptacle[TimestampSecond] = "from".as[TimestampSecond](timestampSecondUnmarshaller).?(TimestampSecond.now() - 1.day) val toFormParam: NameDefaultUnmarshallerReceptacle[TimestampSecond] = "to".as[TimestampSecond](timestampSecondUnmarshaller).?(TimestampSecond.now()) val amountMsatFormParam: NameReceptacle[MilliSatoshi] = "amountMsat".as[MilliSatoshi] - val amountSatFormParam: NameReceptacle[Satoshi] = "amountSat".as[Satoshi] val invoiceFormParam: NameReceptacle[Bolt11Invoice] = "invoice".as[Bolt11Invoice] val routeFormatFormParam: NameUnmarshallerReceptacle[RouteFormat] = "format".as[RouteFormat](routeFormatUnmarshaller) val ignoreNodeIdsFormParam: NameUnmarshallerReceptacle[List[PublicKey]] = "ignoreNodeIds".as[List[PublicKey]](pubkeyListUnmarshaller) val ignoreShortChannelIdsFormParam: NameUnmarshallerReceptacle[List[ShortChannelId]] = "ignoreShortChannelIds".as[List[ShortChannelId]](shortChannelIdsUnmarshaller) val maxFeeMsatFormParam: NameReceptacle[MilliSatoshi] = "maxFeeMsat".as[MilliSatoshi] - val swapIdFormParam: NameUnmarshallerReceptacle[ByteVector32] = "swapId".as[ByteVector32](sha256HashUnmarshaller) // custom directive to fail with HTTP 404 (and JSON response) if the element was not found def completeOrNotFound[T](fut: Future[Option[T]])(implicit marshaller: ToResponseMarshaller[T]): Route = onComplete(fut) { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala deleted file mode 100644 index 205f5324fe..0000000000 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PeerSwap.scala +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.eclair.api.handlers - -import akka.http.scaladsl.server.Route -import fr.acinq.eclair.api.Service -import fr.acinq.eclair.api.directives.EclairDirectives -import fr.acinq.eclair.api.serde.FormParamExtractors._ - -trait PeerSwap { - this: Service with EclairDirectives => - - import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization} - - val swapIn: Route = postRequest("swapin") { implicit t => - formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) => - complete(eclairApi.swapIn(channelId, amount)) - } - } - - val swapOut: Route = postRequest("swapout") { implicit t => - formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) => - complete(eclairApi.swapOut(channelId, amount)) - } - } - - val listSwaps: Route = postRequest("listswaps") { implicit t => - complete(eclairApi.listSwaps()) - } - - val cancelSwap: Route = postRequest("cancelswap") { implicit t => - formFields(swapIdFormParam) { swapId => - complete(eclairApi.cancelSwap(swapId.toString())) - } - } - - val peerSwapRoutes: Route = swapIn ~ swapOut ~ listSwaps ~ cancelSwap - -} From eb91acbda780157ca46f9f71548f3272ad745010 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 20 Oct 2022 21:40:49 +0200 Subject: [PATCH 19/23] Move Peerswap functionality into a plugin --- plugins/peerswap/README.md | 28 +++ plugins/peerswap/pom.xml | 163 ++++++++++++++++++ .../eclair/plugins/peerswap/ApiHandlers.scala | 66 +++++++ .../peerswap}/LocalSwapKeyManager.scala | 2 +- .../plugins/peerswap/PeerSwapPlugin.scala | 101 +++++++++++ .../plugins/peerswap}/SwapCommands.scala | 12 +- .../eclair/plugins/peerswap}/SwapData.scala | 11 +- .../eclair/plugins/peerswap}/SwapEvents.scala | 2 +- .../plugins/peerswap}/SwapHelpers.scala | 34 ++-- .../plugins/peerswap}/SwapKeyManager.scala | 2 +- .../eclair/plugins/peerswap}/SwapMaker.scala | 35 ++-- .../plugins/peerswap}/SwapRegister.scala | 52 ++++-- .../plugins/peerswap}/SwapResponses.scala | 4 +- .../plugins/peerswap}/SwapScripts.scala | 2 +- .../eclair/plugins/peerswap}/SwapTaker.scala | 35 ++-- .../plugins/peerswap/db/DualSwapsDb.scala | 55 ++++++ .../eclair/plugins/peerswap}/db/SwapsDb.scala | 10 +- .../plugins/peerswap}/db/pg/PgSwapsDb.scala | 12 +- .../peerswap}/db/sqlite/SqliteSwapsDb.scala | 12 +- .../json/PeerSwapJsonSerializers.scala | 5 +- .../transactions}/SwapTransactions.scala | 14 +- .../wire/protocol/PeerSwapMessageCodecs.scala | 15 +- .../wire/protocol/PeerSwapMessageTypes.scala | 67 +++++++ .../plugins/peerswap}/PeerSwapSpec.scala | 30 +++- .../peerswap}/SwapInReceiverSpec.scala | 30 ++-- .../plugins/peerswap}/SwapInSenderSpec.scala | 38 ++-- .../peerswap}/SwapIntegrationFixture.scala | 25 ++- .../peerswap}/SwapIntegrationSpec.scala | 75 +++++--- .../peerswap}/SwapOutReceiverSpec.scala | 31 ++-- .../plugins/peerswap}/SwapOutSenderSpec.scala | 31 ++-- .../plugins/peerswap}/SwapRegisterSpec.scala | 114 ++++++------ .../plugins/peerswap}/db/SwapsDbSpec.scala | 95 +++++----- .../json}/PeerSwapJsonSerializersSpec.scala | 11 +- .../transactions}/SwapTransactionsSpec.scala | 10 +- .../protocol}/PeerSwapMessageCodecsSpec.scala | 10 +- pom.xml | 1 + 36 files changed, 906 insertions(+), 334 deletions(-) create mode 100644 plugins/peerswap/README.md create mode 100644 plugins/peerswap/pom.xml create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/LocalSwapKeyManager.scala (99%) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapCommands.scala (93%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapData.scala (75%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapEvents.scala (97%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapHelpers.scala (87%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapKeyManager.scala (98%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapMaker.scala (94%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapRegister.scala (73%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapResponses.scala (94%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapScripts.scala (98%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/SwapTaker.scala (95%) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala rename {eclair-core/src/main/scala/fr/acinq/eclair => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/db/SwapsDb.scala (91%) rename {eclair-core/src/main/scala/fr/acinq/eclair => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/db/pg/PgSwapsDb.scala (91%) rename {eclair-core/src/main/scala/fr/acinq/eclair => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/db/sqlite/SqliteSwapsDb.scala (90%) rename {eclair-core/src/main/scala/fr/acinq/eclair => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/json/PeerSwapJsonSerializers.scala (95%) rename {eclair-core/src/main/scala/fr/acinq/eclair/swap => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions}/SwapTransactions.scala (90%) rename {eclair-core/src/main/scala/fr/acinq/eclair => plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap}/wire/protocol/PeerSwapMessageCodecs.scala (84%) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageTypes.scala rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/PeerSwapSpec.scala (51%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/SwapInReceiverSpec.scala (88%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/SwapInSenderSpec.scala (85%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/SwapIntegrationFixture.scala (60%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/SwapIntegrationSpec.scala (82%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/SwapOutReceiverSpec.scala (81%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/SwapOutSenderSpec.scala (84%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/SwapRegisterSpec.scala (62%) rename {eclair-core/src/test/scala/fr/acinq/eclair => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap}/db/SwapsDbSpec.scala (63%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json}/PeerSwapJsonSerializersSpec.scala (95%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions}/SwapTransactionsSpec.scala (95%) rename {eclair-core/src/test/scala/fr/acinq/eclair/swap => plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol}/PeerSwapMessageCodecsSpec.scala (97%) diff --git a/plugins/peerswap/README.md b/plugins/peerswap/README.md new file mode 100644 index 0000000000..7e6fb8dfd1 --- /dev/null +++ b/plugins/peerswap/README.md @@ -0,0 +1,28 @@ +# Peerswap plugin + +This plugin allows implements the PeerSwap protocol: https://github.com/ElementsProject/peerswap-spec/blob/main/peer-protocol.md + +## Build + +To build this plugin, run the following command in this directory: + +```sh +mvn package +``` + +## Run + +To run eclair with this plugin, start eclair with the following command: + +```sh +eclair-node-/bin/eclair-node.sh /peerswap-plugin-.jar +``` + +## Commands + +```sh +eclair-cli swapin --shortChannelId=> --amountSat= +eclair-cli swapout --shortChannelId=> --amountSat= +eclair-cli listswaps +eclair-cli cancelswap --swapId= +``` \ No newline at end of file diff --git a/plugins/peerswap/pom.xml b/plugins/peerswap/pom.xml new file mode 100644 index 0000000000..b76826a23f --- /dev/null +++ b/plugins/peerswap/pom.xml @@ -0,0 +1,163 @@ + + + + + 4.0.0 + + fr.acinq.eclair + eclair_2.13 + 0.7.1-SNAPSHOT + + + peerswap-plugin_2.13 + jar + peerswap-plugin + + + + + com.googlecode.maven-download-plugin + download-maven-plugin + 1.3.0 + + + download-bitcoind + generate-test-resources + + wget + + + ${maven.test.skip} + ${bitcoind.url} + true + ${project.build.directory} + ${bitcoind.md5} + ${bitcoind.sha1} + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 + + + + + + fr.acinq.eclair.plugins.peerswap.PeerSwapPlugin + + + + + + + package + + shade + + + + + + + + + + default + + true + + + https://bitcoincore.org/bin/bitcoin-core-0.21.1/bitcoin-0.21.1-x86_64-linux-gnu.tar.gz + e283a98b5e9f0b58e625e1dde661201d + 5101e29b39c33cc8e40d5f3b46dda37991b037a0 + + + + Mac + + + mac + + + + https://bitcoincore.org/bin/bitcoin-core-0.21.1/bitcoin-0.21.1-osx64.tar.gz + dfd1f323678eede14ae2cf6afb26ff6a + 4273696f90a2648f90142438221f5d1ade16afa2 + + + + Windows + + + Windows + + + + https://bitcoincore.org/bin/bitcoin-core-0.21.1/bitcoin-0.21.1-win64.zip + 1c6f5081ea68dcec7eddb9e6cdfc508d + a782cd413fc736f05fad3831d6a9f59dde779520 + + + + + + + org.scala-lang + scala-library + ${scala.version} + provided + + + fr.acinq.eclair + eclair-core_${scala.version.short} + ${project.version} + provided + + + fr.acinq.eclair + eclair-node_${scala.version.short} + ${project.version} + provided + + + + com.typesafe.akka + akka-testkit_${scala.version.short} + ${akka.version} + test + + + com.typesafe.akka + akka-actor-testkit-typed_${scala.version.short} + ${akka.version} + test + + + fr.acinq.eclair + eclair-core_${scala.version.short} + ${project.version} + tests + test-jar + test + + + + diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala new file mode 100644 index 0000000000..330f6d0184 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.http.scaladsl.common.{NameReceptacle, NameUnmarshallerReceptacle} +import akka.http.scaladsl.server.Route +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.api.serde.FormParamExtractors._ + +object ApiHandlers { + + import fr.acinq.eclair.api.serde.JsonSupport.{marshaller, serialization} + import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers.formats + + def registerRoutes(kit: PeerSwapKit, eclairDirectives: EclairDirectives): Route = { + import eclairDirectives._ + + val swapIdFormParam: NameUnmarshallerReceptacle[ByteVector32] = "swapId".as[ByteVector32](sha256HashUnmarshaller) + + val amountSatFormParam: NameReceptacle[Satoshi] = "amountSat".as[Satoshi] + + val swapIn: Route = postRequest("swapin") { implicit t => + formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) => + complete(kit.swapIn(channelId, amount)) + } + } + + val swapOut: Route = postRequest("swapout") { implicit t => + formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) => + complete(kit.swapOut(channelId, amount)) + } + } + + val listSwaps: Route = postRequest("listswaps") { implicit t => + complete(kit.listSwaps()) + } + + val cancelSwap: Route = postRequest("cancelswap") { implicit t => + formFields(swapIdFormParam) { swapId => + complete(kit.cancelSwap(swapId.toString())) + } + } + + val peerSwapRoutes: Route = swapIn ~ swapOut ~ listSwaps ~ cancelSwap + + peerSwapRoutes + } + +} + + diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/LocalSwapKeyManager.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/LocalSwapKeyManager.scala similarity index 99% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/LocalSwapKeyManager.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/LocalSwapKeyManager.scala index d226b5dee9..0fafb2d965 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/LocalSwapKeyManager.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/LocalSwapKeyManager.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache} import fr.acinq.bitcoin.scalacompat.DeterministicWallet._ diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala new file mode 100644 index 0000000000..5292ed05cd --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala @@ -0,0 +1,101 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.ActorSystem +import akka.actor.typed.scaladsl.AskPattern.Askable +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, ClassicSchedulerOps} +import akka.actor.typed.{ActorRef, SupervisorStrategy} +import akka.http.scaladsl.server.Route +import akka.util.Timeout +import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.db.sqlite.SqliteUtils +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status} +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.{CustomFeaturePlugin, Feature, InitFeature, Kit, NodeFeature, NodeParams, Plugin, PluginParams, RouteProvider, Setup, ShortChannelId} +import grizzled.slf4j.Logging +import scodec.bits.ByteVector + +import java.io.File +import java.nio.file.Files +import scala.concurrent.Future + +/** + * This plugin implements the PeerSwap protocol: https://github.com/ElementsProject/peerswap-spec/blob/main/peer-protocol.md + */ +object PeerSwapPlugin { + // TODO: derive this set from peerSwapMessageCodec tags + val peerSwapTags: Set[Int] = Set(42069, 42071, 42073, 42075, 42077, 42079, 42081) +} + +class PeerSwapPlugin extends Plugin with RouteProvider with Logging { + + var db: SwapsDb = _ + var swapKeyManager: LocalSwapKeyManager = _ + var pluginKit: PeerSwapKit = _ + + case object PeerSwapFeature extends Feature with InitFeature with NodeFeature { + val rfcName = "peer_swap_plugin_prototype" + val mandatory = 158 + } + + override def params: PluginParams = new CustomFeaturePlugin { + // @formatter:off + override def messageTags: Set[Int] = PeerSwapPlugin.peerSwapTags + override def feature: Feature = PeerSwapFeature + override def name: String = "PeerSwap" + // @formatter:on + } + + override def onSetup(setup: Setup): Unit = { + val chain = setup.config.getString("chain") + val chainDir = new File(setup.datadir, chain) + db = new SqliteSwapsDb(SqliteUtils.openSqliteFile(chainDir, "peer-swap.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "normal")) + + // load seed + val seedFilename: String = "swap_seed.dat" + val seedPath: File = new File(setup.datadir, seedFilename) + val swapSeed: ByteVector = ByteVector(Files.readAllBytes(seedPath.toPath)) + swapKeyManager = new LocalSwapKeyManager(swapSeed, NodeParams.hashFromChain(chain)) + } + + override def onKit(kit: Kit): Unit = { + val data = db.restore().toSet + val swapRegister = kit.system.spawn(Behaviors.supervise(SwapRegister(kit.nodeParams, kit.paymentInitiator, kit.watcher, kit.register, kit.wallet, swapKeyManager, db, data)).onFailure(SupervisorStrategy.restart), "peerswap-plugin-swap-register") + pluginKit = PeerSwapKit(kit.nodeParams, kit.system, swapRegister) + } + + override def route(eclairDirectives: EclairDirectives): Route = ApiHandlers.registerRoutes(pluginKit, eclairDirectives) + +} + +case class PeerSwapKit(nodeParams: NodeParams, system: ActorSystem, swapRegister: ActorRef[SwapRegister.Command]) { + def swapIn(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = + swapRegister.ask(ref => SwapRegister.SwapInRequested(ref, amount, shortChannelId))(timeout, system.scheduler.toTyped) + + def swapOut(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = + swapRegister.ask(ref => SwapRegister.SwapOutRequested(ref, amount, shortChannelId))(timeout, system.scheduler.toTyped) + + def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] = + swapRegister.ask(ref => SwapRegister.ListPendingSwaps(ref))(timeout, system.scheduler.toTyped) + + def cancelSwap(swapId: String)(implicit timeout: Timeout): Future[Response] = + swapRegister.ask(ref => SwapRegister.CancelSwapRequested(ref, swapId))(timeout, system.scheduler.toTyped) +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala similarity index 93% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala index 79037bbdd0..74b7de5d4e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.typed.ActorRef import fr.acinq.bitcoin.scalacompat.Satoshi @@ -23,9 +23,9 @@ import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchOutputSpentTriggered, WatchTxConfirmedTriggered} import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} -import fr.acinq.eclair.swap.SwapData._ -import fr.acinq.eclair.swap.SwapResponses.{Response, Status} -import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInRequest, SwapOutRequest} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInRequest, SwapOutRequest} +import fr.acinq.eclair.wire.protocol.UnknownMessage object SwapCommands { @@ -45,7 +45,7 @@ object SwapCommands { sealed trait AwaitAgreementMessages extends SwapCommand case class SwapMessageReceived(message: HasSwapId) extends AwaitAgreementMessages with CreateOpeningTxMessages with AwaitClaimPaymentMessages with AwaitFeePaymentMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages - case class ForwardFailureAdapter(result: Register.ForwardFailure[HasSwapId]) extends AwaitAgreementMessages + case class ForwardFailureAdapter(result: Register.ForwardFailure[UnknownMessage]) extends AwaitAgreementMessages sealed trait CreateOpeningTxMessages extends SwapCommand case class InvoiceResponse(invoice: Bolt11Invoice) extends CreateOpeningTxMessages @@ -80,7 +80,7 @@ object SwapCommands { sealed trait SendAgreementMessages extends SwapCommand sealed trait AwaitFeePaymentMessages extends SwapCommand - case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[HasSwapId]) extends AwaitFeePaymentMessages with SendCoopCloseMessages with SendAgreementMessages + case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[UnknownMessage]) extends AwaitFeePaymentMessages with SendCoopCloseMessages with SendAgreementMessages sealed trait ValidateTxMessages extends SwapCommand case class ValidInvoice(invoice: Bolt11Invoice) extends ValidateTxMessages diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala similarity index 75% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala index 8372181e1d..a10eebf8a4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapData.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala @@ -14,17 +14,16 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import fr.acinq.eclair.payment.Bolt11Invoice -import fr.acinq.eclair.swap -import fr.acinq.eclair.swap.SwapRole.SwapRole -import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapAgreement, SwapRequest} +import fr.acinq.eclair.plugins.peerswap.SwapRole.SwapRole +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapAgreement, SwapRequest} object SwapRole extends Enumeration { type SwapRole = Value - val Maker: swap.SwapRole.Value = Value(1, "Maker") - val Taker: swap.SwapRole.Value = Value(2, "Taker") + val Maker: SwapRole.Value = Value(1, "Maker") + val Taker: SwapRole.Value = Value(2, "Taker") } case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, swapRole: SwapRole, isInitiator: Boolean) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala similarity index 97% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala index 94046592b2..0debd87535 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapEvents.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import fr.acinq.bitcoin.scalacompat.Transaction import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala similarity index 87% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala index c8eaff12f1..4dfdfba580 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapHelpers.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor import akka.actor.typed.eventstream.EventStream @@ -33,11 +33,13 @@ import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANN import fr.acinq.eclair.db.PaymentType import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapEvents.TransactionPublished -import fr.acinq.eclair.swap.SwapTransactions.makeSwapOpeningTxOut +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.TransactionPublished +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.makeSwapOpeningTxOut +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted} import fr.acinq.eclair.transactions.Transactions.{TransactionWithInputInfo, checkSpendable} -import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted} +import fr.acinq.eclair.wire.protocol.UnknownMessage import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond, randomBytes32} import scala.concurrent.ExecutionContext.Implicits.global @@ -87,17 +89,23 @@ object SwapHelpers { def paymentEventAdapter(context: ActorContext[SwapCommand]): ActorRef[PaymentEvent] = context.messageAdapter[PaymentEvent](PaymentEventReceived) - def sendShortId(register: actor.ActorRef, shortChannelId: ShortChannelId)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = - register ! Register.ForwardShortId[HasSwapId](forwardShortIdAdapter(context), shortChannelId, message) + def sendShortId(register: actor.ActorRef, shortChannelId: ShortChannelId)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = { + val encoded = peerSwapMessageCodecWithFallback.encode(message).require + val unknownMessage = UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.toByteVector) + register ! Register.ForwardShortId(forwardShortIdAdapter(context), shortChannelId, unknownMessage) + } - def forwardShortIdAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortIdFailure[HasSwapId]] = - context.messageAdapter[Register.ForwardShortIdFailure[HasSwapId]](ForwardShortIdFailureAdapter) + def forwardShortIdAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortIdFailure[UnknownMessage]] = + context.messageAdapter[Register.ForwardShortIdFailure[UnknownMessage]](ForwardShortIdFailureAdapter) - def send(register: actor.ActorRef, channelId: ByteVector32)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = - register ! Register.Forward(forwardAdapter(context), channelId, message) + def send(register: actor.ActorRef, channelId: ByteVector32)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = { + val encoded = peerSwapMessageCodecWithFallback.encode(message).require + val unknownMessage = UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.toByteVector) + register ! Register.Forward(forwardAdapter(context), channelId, unknownMessage) + } - def forwardAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[HasSwapId]] = - context.messageAdapter[Register.ForwardFailure[HasSwapId]](ForwardFailureAdapter) + def forwardAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[UnknownMessage]] = + context.messageAdapter[Register.ForwardFailure[UnknownMessage]](ForwardFailureAdapter) def fundOpening(wallet: OnChainWallet, feeRatePerKw: FeeratePerKw)(amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand]): Unit = { // setup conditions satisfied, create the opening tx diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapKeyManager.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapKeyManager.scala similarity index 98% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapKeyManager.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapKeyManager.scala index 87b6738721..c72bcfe3c5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapKeyManager.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapKeyManager.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import fr.acinq.bitcoin.scalacompat.DeterministicWallet.{ExtendedPrivateKey, ExtendedPublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector64, DeterministicWallet, Protocol} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala similarity index 94% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala index e1447cde19..cae2594889 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapMaker.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor import akka.actor.typed.eventstream.EventStream.Publish @@ -31,15 +31,15 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedT import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceivePayment} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapEvents._ -import fr.acinq.eclair.swap.SwapHelpers._ -import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} -import fr.acinq.eclair.swap.SwapRole.Maker -import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta -import fr.acinq.eclair.swap.SwapTransactions._ -import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx} -import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapHelpers._ +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} +import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker +import fr.acinq.eclair.plugins.peerswap.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond} import scodec.bits.ByteVector @@ -106,22 +106,22 @@ object SwapMaker { */ - def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet): Behavior[SwapCommands.SwapCommand] = + def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommands.SwapCommand] = Behaviors.setup { context => Behaviors.receiveMessagePartial { case StartSwapInSender(amount, swapId, shortChannelId) => - new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, context) + new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, keyManager, db, context) .createSwap(amount, swapId) case StartSwapOutReceiver(request: SwapOutRequest) => ShortChannelId.fromCoordinates(request.scid) match { - case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, keyManager, db, context) .validateRequest(request) case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped } case RestoreSwap(d) => ShortChannelId.fromCoordinates(d.request.scid) match { - case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, keyManager, db, context) .awaitClaimPayment(d.request, d.agreement, d.invoice, d.openingTxBroadcasted, d.isInitiator) case Failure(e) => context.log.error(s"could not restore swap sender with invalid shortChannelId: $d, $e") Behaviors.stopped @@ -131,11 +131,10 @@ object SwapMaker { } } -private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { +private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds - private val keyManager: SwapKeyManager = nodeParams.swapKeyManager private implicit val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) private val openingFee = (feeRatePerKw * openingTxWeight / 1000).toLong // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? private val maxPremium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap sender calculate an acceptable premium? @@ -223,7 +222,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") Behaviors.same case OpeningTxCommitted(invoice, openingTxBroadcasted) => - nodeParams.db.swaps.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator)) + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator)) awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted, isInitiator) case OpeningTxFailed(error, None) => swapCanceled(InternalError(request.swapId, s"failed to fund swap open tx, error: $error")) case OpeningTxFailed(error, Some(r)) => rollback(wallet)(error, r.fundingTx) @@ -338,7 +337,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { context.system.eventStream ! Publish(event) context.log.info(s"completed swap: $event.") - nodeParams.db.swaps.addResult(event) + db.addResult(event) Behaviors.stopped } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala similarity index 73% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala index e15521a531..2ee0b3c0e1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala @@ -14,22 +14,27 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor import akka.actor.typed import akka.actor.typed.ActorRef.ActorRefOps import akka.actor.typed.scaladsl.AskPattern.Askable +import akka.actor.typed.scaladsl.adapter.TypedActorRefOps import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy} import fr.acinq.bitcoin.scalacompat.Satoshi import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapRegister.Command -import fr.acinq.eclair.swap.SwapResponses.{Response, Status, SwapOpened} -import fr.acinq.eclair.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest} +import fr.acinq.eclair.io.UnknownMessageReceived +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapRegister.Command +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest} import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} +import scodec.Attempt import scala.concurrent.duration.DurationInt import scala.concurrent.{Await, Future} @@ -43,6 +48,7 @@ object SwapRegister { } sealed trait RegisteringMessages extends Command + case class PluginMessageReceived(message: UnknownMessageReceived) extends RegisteringMessages case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages case class SwapOutRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages case class MessageReceived(message: HasSwapId) extends RegisteringMessages @@ -51,12 +57,12 @@ object SwapRegister { case class CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) extends RegisteringMessages with ReplyToMessages // @formatter:on - def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData]): Behavior[Command] = Behaviors.setup { context => - new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, wallet, data).initializing + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, data: Set[SwapData]): Behavior[Command] = Behaviors.setup { context => + new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, data).initializing } } -private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, data: Set[SwapData]) { +private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, data: Set[SwapData]) { import SwapRegister._ private def myReceive[B <: Command : ClassTag](stateName: String)(f: B => Behavior[Command]): Behavior[Command] = @@ -72,9 +78,9 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam val swaps = data.map { state => val swap: typed.ActorRef[SwapCommands.SwapCommand] = { state.swapRole match { - case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) + case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) .onFailure(typed.SupervisorStrategy.restart), "SwapMaker-" + state.request.scid) - case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet)) + case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) .onFailure(typed.SupervisorStrategy.restart), "SwapTaker-" + state.request.scid) } } @@ -85,13 +91,22 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam registering(swaps) } + def watchForUnknownMessage(watch: Boolean)(implicit context: ActorContext[Command]): Unit = + if (watch) context.system.classicSystem.eventStream.subscribe(unknownMessageAdapter(context).toClassic, classOf[UnknownMessageReceived]) + else context.system.classicSystem.eventStream.unsubscribe(unknownMessageAdapter(context).toClassic, classOf[UnknownMessageReceived]) + + def unknownMessageAdapter(context: ActorContext[Command]): ActorRef[UnknownMessageReceived] = { + context.messageAdapter[UnknownMessageReceived](PluginMessageReceived) + } + private def registering(swaps: Map[String, ActorRef[SwapCommands.SwapCommand]]): Behavior[Command] = { // TODO: fail requests for swaps on a channel if one already exists for the channel; keep a list of channels with active swaps // TODO: check currently registered swaps, and swap db, to prevent reuse of a swapId + watchForUnknownMessage(watch = true)(context) myReceive[RegisteringMessages]("registering") { case SwapInRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex - val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapInSender(amount, swapId, shortChannelId) @@ -100,7 +115,7 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam case SwapOutRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex - val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet)) + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapOutSender(amount, swapId, shortChannelId) @@ -108,19 +123,28 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam registering(swaps + (swapId -> swap)) case MessageReceived(request: SwapInRequest) => - val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet)) + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) .onFailure(SupervisorStrategy.restart), "Swap-"+ request.scid) context.watchWith(swap, SwapTerminated(request.swapId)) swap ! StartSwapInReceiver(request) registering(swaps + (request.swapId -> swap)) case MessageReceived(request: SwapOutRequest) => - val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet)) + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) context.watchWith(swap, SwapTerminated(request.swapId)) swap ! StartSwapOutReceiver(request) registering(swaps + (request.swapId -> swap)) + case PluginMessageReceived(unknownMessageReceived) => + if (PeerSwapPlugin.peerSwapTags.contains(unknownMessageReceived.message.tag)) { + peerSwapMessageCodec.decode(unknownMessageReceived.message.data.toBitVector) match { + case Attempt.Successful(m) => context.self ! MessageReceived(m.value) + case _ => context.log.error(s"could not decode peerswap message $unknownMessageReceived") + } + } + Behaviors.same + case MessageReceived(msg) => swaps.get(msg.swapId) match { case Some(swap) => swap ! SwapMessageReceived(msg) Behaviors.same diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala similarity index 94% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala index 1846a8574f..b90ce77540 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala @@ -14,10 +14,10 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import fr.acinq.eclair.payment.Bolt11Invoice -import fr.acinq.eclair.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapAgreement, SwapRequest} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapAgreement, SwapRequest} object SwapResponses { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapScripts.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapScripts.scala similarity index 98% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapScripts.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapScripts.scala index 8d527d7c5a..102ce3868e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapScripts.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapScripts.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat._ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala similarity index 95% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala index 7a17630213..753c90857d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTaker.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor import akka.actor.typed.eventstream.EventStream.Publish @@ -28,14 +28,14 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpentTriggered, WatchTxConfirmedTriggered} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentFailed, PaymentSent} -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapEvents._ -import fr.acinq.eclair.swap.SwapHelpers._ -import fr.acinq.eclair.swap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} -import fr.acinq.eclair.swap.SwapRole.Taker -import fr.acinq.eclair.swap.SwapTransactions._ -import fr.acinq.eclair.transactions.Transactions.SwapClaimByCoopTx -import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapHelpers._ +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} +import fr.acinq.eclair.plugins.peerswap.SwapRole.Taker +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import fr.acinq.eclair.{NodeParams, ShortChannelId, ToMilliSatoshiConversion} import scodec.bits.ByteVector @@ -102,22 +102,22 @@ object SwapTaker { */ - def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet): Behavior[SwapCommand] = + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommand] = Behaviors.setup { context => Behaviors.receiveMessagePartial { case StartSwapOutSender(amount, swapId, shortChannelId) => - new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) .createSwap(amount, swapId) case StartSwapInReceiver(request: SwapInRequest) => ShortChannelId.fromCoordinates(request.scid) match { - case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) .validateRequest(request) case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped } case RestoreSwap(d) => ShortChannelId.fromCoordinates(d.request.scid) match { - case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, context) + case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) .awaitOpeningTxConfirmed(d.request, d.agreement, d.openingTxBroadcasted, d.isInitiator) case Failure(e) => context.log.error(s"could not restore swap receiver with invalid shortChannelId: $d, $e") Behaviors.stopped @@ -127,12 +127,11 @@ object SwapTaker { } } -private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, implicit val context: ActorContext[SwapCommands.SwapCommand]) { +private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 2 val noAsset = "" implicit val timeout: Timeout = 30 seconds - private val keyManager: SwapKeyManager = nodeParams.swapKeyManager private val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) private val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat // TODO: how should swap receiver calculate an acceptable premium? private val maxOpeningFee = (feeRatePerKw * openingTxWeight / 1000).toLong.sat // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? @@ -262,7 +261,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, receiveSwapMessage[ValidateTxMessages](context, "validateOpeningTx") { case ValidInvoice(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => - nodeParams.db.swaps.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator)) + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator)) payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, openingTx, isInitiator) case ValidInvoice(_) => sendCoopClose(request,s"Invalid opening tx: $openingTx", Some(openingTxBroadcasted)) case InvalidInvoice(reason) => sendCoopClose(request, reason, Some(openingTxBroadcasted)) @@ -291,8 +290,8 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, } def claimSwap(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { - val inputInfo = makeSwapOpeningInputInfo(openingTx.hash, openingTxBroadcasted.scriptOut.toInt, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) - val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPrivkey(request.swapId), paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTx.txid, openingTxBroadcasted.scriptOut.toInt, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPrivkey(request.swapId), paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) def claimByInvoiceConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala new file mode 100644 index 0000000000..54ec84eae3 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala @@ -0,0 +1,55 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.db + +import com.google.common.util.concurrent.ThreadFactoryBuilder +import fr.acinq.eclair.db.DualDatabases.runAsync +import fr.acinq.eclair.plugins.peerswap.SwapData +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent + +import java.util.concurrent.Executors +import scala.concurrent.ExecutionContext + +case class DualSwapsDb(primary: SwapsDb, secondary: SwapsDb) extends SwapsDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build())) + + override def add(swapData: SwapData): Unit = { + runAsync(secondary.add(swapData)) + primary.add(swapData) + } + + override def addResult(swapEvent: SwapEvent): Unit = { + runAsync(secondary.addResult(swapEvent)) + primary.addResult(swapEvent) + } + + override def remove(swapId: String): Unit = { + runAsync(secondary.remove(swapId)) + primary.remove(swapId) + } + + override def restore(): Seq[SwapData] = { + runAsync(secondary.restore()) + primary.restore() + } + + override def list(): Seq[SwapData] = { + runAsync(secondary.list()) + primary.list() + } +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/SwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala similarity index 91% rename from eclair-core/src/main/scala/fr/acinq/eclair/db/SwapsDb.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala index 2be2ce2d2a..f36da50f1e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/SwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala @@ -14,13 +14,13 @@ * limitations under the License. */ -package fr.acinq.eclair.db +package fr.acinq.eclair.plugins.peerswap.db import fr.acinq.eclair.payment.Bolt11Invoice -import fr.acinq.eclair.swap.SwapEvents.SwapEvent -import fr.acinq.eclair.swap.SwapRole.Maker -import fr.acinq.eclair.swap.{SwapData, SwapRole} -import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.{SwapData, SwapRole} import org.json4s.jackson.JsonMethods.{compact, parse, render} import org.json4s.jackson.Serialization diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala similarity index 91% rename from eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgSwapsDb.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala index cfacf49137..8b265b5a8a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgSwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala @@ -14,15 +14,15 @@ * limitations under the License. */ -package fr.acinq.eclair.db.pg +package fr.acinq.eclair.plugins.peerswap.db.pg import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends -import fr.acinq.eclair.db.SwapsDb -import fr.acinq.eclair.db.SwapsDb.{getSwapData, setSwapData} import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock -import fr.acinq.eclair.swap.SwapData -import fr.acinq.eclair.swap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.SwapData +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb.{getSwapData, setSwapData} import grizzled.slf4j.Logging import javax.sql.DataSource @@ -34,7 +34,7 @@ object PgSwapsDb { class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { - import PgUtils._ + import fr.acinq.eclair.db.pg.PgUtils._ import ExtendedResultSet._ import PgSwapsDb._ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala similarity index 90% rename from eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteSwapsDb.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala index 56f13623d7..5bf480d0ac 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteSwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala @@ -14,14 +14,14 @@ * limitations under the License. */ -package fr.acinq.eclair.db.sqlite +package fr.acinq.eclair.plugins.peerswap.db.sqlite import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends -import fr.acinq.eclair.db.SwapsDb -import fr.acinq.eclair.db.SwapsDb.{getSwapData, setSwapData} -import fr.acinq.eclair.swap.SwapData -import fr.acinq.eclair.swap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.SwapData +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb.{getSwapData, setSwapData} import grizzled.slf4j.Logging import java.sql.Connection @@ -33,7 +33,7 @@ object SqliteSwapsDb { class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { - import SqliteUtils._ + import fr.acinq.eclair.db.sqlite.SqliteUtils._ import ExtendedResultSet._ import SqliteSwapsDb._ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/PeerSwapJsonSerializers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializers.scala similarity index 95% rename from eclair-core/src/main/scala/fr/acinq/eclair/json/PeerSwapJsonSerializers.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializers.scala index 9c446963c6..6f473d1d40 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/PeerSwapJsonSerializers.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializers.scala @@ -14,9 +14,10 @@ * limitations under the License. */ -package fr.acinq.eclair.json +package fr.acinq.eclair.plugins.peerswap.json -import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.json.MinimalSerializer +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import org.json4s.JsonAST._ import org.json4s.jackson.Serialization import org.json4s.{Formats, JField, JObject, JString, jackson} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala similarity index 90% rename from eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala index 49e1d401e7..a0a399dba0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapTransactions.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap.transactions import fr.acinq.bitcoin.SigHash.SIGHASH_ALL import fr.acinq.bitcoin.SigVersion.SIGVERSION_WITNESS_V0 @@ -22,16 +22,18 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.Script._ import fr.acinq.bitcoin.scalacompat.{TxOut, _} import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.swap.SwapScripts._ +import fr.acinq.eclair.plugins.peerswap.SwapScripts._ import fr.acinq.eclair.transactions.Scripts.der -import fr.acinq.eclair.transactions.Transactions.{InputInfo, weight2fee} +import fr.acinq.eclair.transactions.Transactions.{InputInfo, TransactionWithInputInfo, weight2fee} import scodec.bits.ByteVector -/** - * Created by remyers on 06/05/2022. - */ object SwapTransactions { + // TODO: find alternative to unsealing TransactionWithInputInfo + case class SwapClaimByInvoiceTx(override val input: InputInfo, override val tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbyinvoice-tx" } + case class SwapClaimByCoopTx(override val input: InputInfo, override val tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycoop-tx" } + case class SwapClaimByCsvTx(override val input: InputInfo, override val tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycsv-tx" } + /** * This default sig takes 72B when encoded in DER (incl. 1B for the trailing sig hash), it is used for fee estimation * It is 72 bytes because our signatures are normalized (low-s) and will take up 72 bytes at most in DER format diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PeerSwapMessageCodecs.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecs.scala similarity index 84% rename from eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PeerSwapMessageCodecs.scala rename to plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecs.scala index f99c0770f9..057ce7578e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PeerSwapMessageCodecs.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecs.scala @@ -14,10 +14,10 @@ * limitations under the License. */ -package fr.acinq.eclair.wire.protocol +package fr.acinq.eclair.plugins.peerswap.wire.protocol import fr.acinq.eclair.KamonExt -import fr.acinq.eclair.json.PeerSwapJsonSerializers.formats +import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers.formats import fr.acinq.eclair.wire.Monitoring.{Metrics, Tags} import fr.acinq.eclair.wire.protocol.CommonCodecs._ import org.json4s._ @@ -27,9 +27,6 @@ import scodec.bits.BitVector import scodec.codecs._ import scodec.{Attempt, Codec} -/** - * Created by remyers on 29/03/2022. - */ object PeerSwapMessageCodecs { val swapInRequestCodec: Codec[SwapInRequest] = limitedSizeBytes(65533, utf8) @@ -65,7 +62,7 @@ object PeerSwapMessageCodecs { ("message" | varsizebinarydata) ).as[UnknownPeerSwapMessage] - val peerSwapMessageCodec: DiscriminatorCodec[PeerSwapMessage, Int] = discriminated[PeerSwapMessage].by(uint16) + val peerSwapMessageCodec: DiscriminatorCodec[HasSwapId, Int] = discriminated[HasSwapId].by(uint16) .typecase(42069, swapInRequestCodec) .typecase(42071, swapOutRequestCodec) .typecase(42073, swapInAgreementCodec) @@ -74,10 +71,10 @@ object PeerSwapMessageCodecs { .typecase(42079, canceledCodec) .typecase(42081, coopCloseCodec) - val peerSwapMessageCodecWithFallback: Codec[PeerSwapMessage] = discriminatorWithDefault(peerSwapMessageCodec, unknownPeerSwapMessageCodec.upcast) + val peerSwapMessageCodecWithFallback: Codec[HasSwapId] = discriminatorWithDefault(peerSwapMessageCodec, unknownPeerSwapMessageCodec.upcast) - val meteredPeerSwapMessageCodec: Codec[PeerSwapMessage] = Codec[PeerSwapMessage]( - (msg: PeerSwapMessage) => KamonExt.time(Metrics.EncodeDuration.withTag(Tags.MessageType, msg.getClass.getSimpleName))(peerSwapMessageCodecWithFallback.encode(msg)), + val meteredPeerSwapMessageCodec: Codec[HasSwapId] = Codec[HasSwapId]( + (msg: HasSwapId) => KamonExt.time(Metrics.EncodeDuration.withTag(Tags.MessageType, msg.getClass.getSimpleName))(peerSwapMessageCodecWithFallback.encode(msg)), (bits: BitVector) => { // this is a bit more involved, because we don't know beforehand what the type of the message will be val begin = System.nanoTime() diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageTypes.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageTypes.scala new file mode 100644 index 0000000000..b55588150c --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageTypes.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.wire.protocol + +import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers +import org.json4s.jackson.Serialization +import scodec.bits.ByteVector + +sealed trait HasSwapId extends Serializable { def swapId: String } + +sealed abstract class JSonBlobMessage() extends HasSwapId { + def json: String = { + Serialization.write(this)(PeerSwapJsonSerializers.formats) + } +} + +sealed trait HasSwapVersion { def protocolVersion: Long} + +sealed trait SwapRequest extends JSonBlobMessage with HasSwapId with HasSwapVersion { + def asset: String + def network: String + def scid: String + def amount: Long + def pubkey: String +} + +case class SwapInRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest + +case class SwapOutRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest + +sealed trait SwapAgreement extends JSonBlobMessage with HasSwapId with HasSwapVersion { + def pubkey: String + def premium: Long + def payreq: String +} + +case class SwapInAgreement(protocolVersion: Long, swapId: String, pubkey: String, premium: Long) extends SwapAgreement { + override def payreq: String = "" +} + +case class SwapOutAgreement(protocolVersion: Long, swapId: String, pubkey: String, payreq: String) extends SwapAgreement { + override def premium: Long = 0 +} + +case class OpeningTxBroadcasted(swapId: String, payreq: String, txId: String, scriptOut: Long, blindingKey: String) extends JSonBlobMessage with HasSwapId + +case class CancelSwap(swapId: String, message: String) extends JSonBlobMessage with HasSwapId + +case class CoopClose(swapId: String, message: String, privkey: String) extends JSonBlobMessage with HasSwapId + +case class UnknownPeerSwapMessage(tag: Int, data: ByteVector) extends HasSwapId { + def swapId: String = "unknown" +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala similarity index 51% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala index 7460f7c58b..3c0502f850 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala @@ -14,20 +14,22 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap -import fr.acinq.bitcoin.scalacompat.Crypto +import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit +import com.typesafe.config.{Config, ConfigFactory} import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.eclair.ShortChannelId +import fr.acinq.bitcoin.scalacompat.{Block, Crypto} +import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager} +import fr.acinq.eclair.{NodeParams, ShortChannelId, TestDatabases, TestFeeEstimator, randomBytes32} import org.scalatest.TryValues.convertTryToSuccessOrFailure -import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.funsuite.AnyFunSuiteLike import scodec.bits._ -/** - * Created by remyers on 04/04/2022. - */ +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong -class PeerSwapSpec extends AnyFunSuite { +class PeerSwapSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with AnyFunSuiteLike { val protocolVersion = 2 val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" val asset = "" @@ -44,4 +46,16 @@ class PeerSwapSpec extends AnyFunSuite { val privkey: PrivateKey = dummyKey(1) def dummyKey(fill: Byte): Crypto.PrivateKey = PrivateKey(ByteVector.fill(32)(fill)) + + val defaultConf: Config = ConfigFactory.load("reference.conf").getConfig("eclair") + + def makeNodeParamsWithDefaults(conf: Config): NodeParams = { + val blockCount = new AtomicLong(0) + val nodeKeyManager = new LocalNodeKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) + val channelKeyManager = new LocalChannelKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) + val feeEstimator = new TestFeeEstimator() + val db = TestDatabases.inMemoryDb() + NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, db, blockCount, feeEstimator) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala similarity index 88% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala index d605b031fc..8527c532a8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} import akka.actor.typed.ActorRef @@ -33,17 +33,21 @@ import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} -import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.swapInAgreementCodec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.wire.protocol.UnknownMessage import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{BeforeAndAfterAll, Outcome} +import java.sql.DriverManager import java.util.UUID import scala.concurrent.duration._ @@ -52,13 +56,14 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. override implicit val timeout: Timeout = Timeout(30 seconds) val protocolVersion = 2 val noAsset = "" - val network: String = NodeParams.chainFromHash(TestConstants.Bob.nodeParams.chainHash) + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) val amount: Satoshi = 1000 sat val swapId: String = ByteVector32.Zeroes.toHex val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId - val keyManager: SwapKeyManager = TestConstants.Bob.nodeParams.swapKeyManager + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) val makerPrivkey: PrivateKey = PrivateKey(randomBytes32()) val takerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey @@ -72,6 +77,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. val scriptOut: Long = 0 val blindingKey: String = "" val request: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message override def withFixture(test: OneArgTest): Outcome = { val watcher = testKit.createTestProbe[ZmqWatcher.Command]() @@ -91,7 +97,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-in-receiver") + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-in-receiver") withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } @@ -138,7 +144,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // SwapInReceiver reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] - val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) monitor.expectMessageType[ClaimTxConfirmed] swapEvents.expectMessageType[ClaimByInvoiceConfirmed] @@ -155,7 +161,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. monitor.expectMessage(StartSwapInReceiver(request)) // SwapInReceiver:SwapInAgreement -> SwapInSender - val agreement = register.expectMessageType[ForwardShortId[SwapInAgreement]].message + val agreement = swapInAgreementCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value // Maker:OpeningTxBroadcasted -> Taker val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) @@ -192,7 +198,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // SwapInReceiver reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] - val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) monitor.expectMessageType[ClaimTxConfirmed] swapEvents.expectMessageType[ClaimByInvoiceConfirmed] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala similarity index 85% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala index beaa1f5f20..7759dfb554 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapInSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} import akka.actor.typed.ActorRef @@ -24,7 +24,7 @@ import akka.actor.typed.scaladsl.adapter._ import akka.util.Timeout import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ @@ -32,16 +32,20 @@ import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapEvents._ -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapInRequestCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{CoopClose, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.wire.protocol.{CoopClose, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} -import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{BeforeAndAfterAll, Outcome} +import java.sql.DriverManager import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} @@ -50,13 +54,14 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo override implicit val timeout: Timeout = Timeout(30 seconds) val protocolVersion = 2 val noAsset = "" - val network: String = Block.RegtestGenesisBlock.hash.toString() + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) val amount: Satoshi = 1000 sat val swapId: String = ByteVector32.Zeroes.toHex val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId - val keyManager: SwapKeyManager = TestConstants.Alice.nodeParams.swapKeyManager + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) val makerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey val takerPrivkey: PrivateKey = PrivateKey(randomBytes32()) val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey @@ -68,6 +73,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo val blindingKey: String = "" val request: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) val agreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId, makerPubkey.toHex, premium) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message override def withFixture(test: OneArgTest): Outcome = { val watcher = testKit.createTestProbe[ZmqWatcher.Command]() @@ -88,7 +94,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // subscribe to notification events from SwapInSender when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-in-sender") + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-in-sender") withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, watcher, wallet, swapEvents))) } @@ -135,17 +141,17 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo swapInSender ! StartSwapInSender(amount, swapId, shortChannelId) // SwapInSender: SwapInRequest -> SwapInSender - val swapInRequest = register.expectMessageType[ForwardShortId[SwapInRequest]] + val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value // SwapInReceiver: SwapInAgreement -> SwapInSender - swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.message.protocolVersion, swapInRequest.message.swapId, takerPubkey.toString(), premium)) + swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, takerPubkey.toString(), premium)) // SwapInSender publishes opening tx on-chain val openingTx = swapEvents.expectMessageType[TransactionPublished].tx // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver - val openingTxBroadcasted = register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] - val invoice = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get + val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + val invoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -176,7 +182,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored - register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] + openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -213,7 +219,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored - register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] + openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value // wait to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationFixture.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala similarity index 60% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationFixture.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala index 29967139bf..7929e6c67c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationFixture.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala @@ -1,6 +1,9 @@ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.ActorSystem +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps} +import akka.actor.typed.{ActorRef, SupervisorStrategy} import akka.testkit.{TestKit, TestProbe} import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchExternalChannelSpent @@ -8,13 +11,16 @@ import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture.{confirmChannel, confirmChannelDeep, connect, getChannelData, getRouterData, openChannel} import fr.acinq.eclair.payment.PaymentEvent -import fr.acinq.eclair.swap.SwapEvents.SwapEvent -import fr.acinq.eclair.{BlockHeight, NodeParams} +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.{BlockHeight, NodeParams, TestConstants} import org.scalatest.concurrent.Eventually.eventually -case class SwapProbes(cli: TestProbe, paymentEvents: TestProbe, swapEvents: TestProbe) +import java.sql.DriverManager -case class SwapIntegrationFixture(system: ActorSystem, alice: MinimalNodeFixture, bob: MinimalNodeFixture, aliceSwap: SwapProbes, bobSwap: SwapProbes, channelId: ByteVector32) { +case class SwapActors(cli: TestProbe, paymentEvents: TestProbe, swapEvents: TestProbe, swapRegister: ActorRef[SwapRegister.Command]) + +case class SwapIntegrationFixture(system: ActorSystem, alice: MinimalNodeFixture, bob: MinimalNodeFixture, aliceSwap: SwapActors, bobSwap: SwapActors, channelId: ByteVector32) { implicit val implicitSystem: ActorSystem = system def cleanup(): Unit = { @@ -25,12 +31,17 @@ case class SwapIntegrationFixture(system: ActorSystem, alice: MinimalNodeFixture } object SwapIntegrationFixture { + def swapRegister(node: MinimalNodeFixture): ActorRef[SwapRegister.Command] = { + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, node.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + node.system.spawn(Behaviors.supervise(SwapRegister(node.nodeParams, node.paymentInitiator, node.watcher.ref.toTyped, node.register, node.wallet, keyManager, db, Set())).onFailure(SupervisorStrategy.stop), s"swap-register-${node.nodeParams.alias}") + } def apply(aliceParams: NodeParams, bobParams: NodeParams): SwapIntegrationFixture = { val system = ActorSystem("system-test") val alice = MinimalNodeFixture(aliceParams) val bob = MinimalNodeFixture(bobParams) - val aliceSwap = SwapProbes(TestProbe()(alice.system), TestProbe()(alice.system), TestProbe()(alice.system)) - val bobSwap = SwapProbes(TestProbe()(bob.system), TestProbe()(bob.system), TestProbe()(bob.system)) + val aliceSwap = SwapActors(TestProbe()(alice.system), TestProbe()(alice.system), TestProbe()(alice.system), swapRegister(alice)) + val bobSwap = SwapActors(TestProbe()(bob.system), TestProbe()(bob.system), TestProbe()(bob.system), swapRegister(bob)) alice.system.eventStream.subscribe(aliceSwap.paymentEvents.ref, classOf[PaymentEvent]) alice.system.eventStream.subscribe(aliceSwap.swapEvents.ref, classOf[SwapEvent]) bob.system.eventStream.subscribe(bobSwap.paymentEvents.ref, classOf[PaymentEvent]) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala similarity index 82% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala index 4c461a7bae..e4e93c16bf 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapIntegrationSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -1,4 +1,4 @@ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.typed.scaladsl.adapter._ import akka.actor.{ActorSystem, Kill} @@ -10,13 +10,15 @@ import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture import fr.acinq.eclair.integration.basic.fixtures.composite.TwoNodesFixture import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentSent} -import fr.acinq.eclair.swap.SwapEvents._ -import fr.acinq.eclair.swap.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapInRequested, SwapOutRequested} -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapOpened} -import fr.acinq.eclair.swap.SwapScripts.claimByCsvDelta -import fr.acinq.eclair.swap.SwapTransactions.{claimByInvoiceTxWeight, openingTxWeight} +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapIntegrationFixture.swapRegister +import fr.acinq.eclair.plugins.peerswap.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapInRequested, SwapOutRequested} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, openingTxWeight} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.SwapInAgreement import fr.acinq.eclair.testutils.FixtureSpec -import fr.acinq.eclair.{BlockHeight, ShortChannelId} +import fr.acinq.eclair.{BlockHeight, ShortChannelId, randomKey} import org.scalatest.TestData import org.scalatest.concurrent.{IntegrationPatience, PatienceConfiguration} import scodec.bits.HexStringSyntax @@ -39,8 +41,9 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { override def createFixture(testData: TestData): FixtureParam = { // seeds have been chosen so that node ids start with 02aaaa for alice, 02bbbb for bob, etc. val aliceParams = nodeParamsFor("alice", ByteVector32(hex"b4acd47335b25ab7b84b8c020997b12018592bb4631b868762154d77fa8b93a3")) + .copy(pluginParams = Seq(new PeerSwapPlugin().params)) val bobParams = nodeParamsFor("bob", ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492")) - .copy(invoiceExpiry = 2 seconds) + .copy(invoiceExpiry = 2 seconds, pluginParams = Seq(new PeerSwapPlugin().params)) TwoNodesFixture(aliceParams, bobParams) } @@ -48,9 +51,9 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { fixture.cleanup() } - def swapProbes(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): (SwapProbes, SwapProbes) = { - val aliceSwap = SwapProbes(TestProbe()(alice.system), TestProbe()(alice.system), TestProbe()(alice.system)) - val bobSwap = SwapProbes(TestProbe()(bob.system), TestProbe()(bob.system), TestProbe()(bob.system)) + def swapActors(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): (SwapActors, SwapActors) = { + val aliceSwap = SwapActors(TestProbe()(alice.system), TestProbe()(alice.system), TestProbe()(alice.system), swapRegister(alice)) + val bobSwap = SwapActors(TestProbe()(bob.system), TestProbe()(bob.system), TestProbe()(bob.system), swapRegister(bob)) alice.system.eventStream.subscribe(aliceSwap.paymentEvents.ref, classOf[PaymentEvent]) alice.system.eventStream.subscribe(aliceSwap.swapEvents.ref, classOf[SwapEvent]) bob.system.eventStream.subscribe(bobSwap.paymentEvents.ref, classOf[PaymentEvent]) @@ -79,7 +82,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { test("swap in - claim by invoice") { f => import f._ - val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val (aliceSwap, bobSwap) = swapActors(alice, bob) val shortChannelId = connectNodes(alice, bob) // bob must have enough on-chain balance to send @@ -91,14 +94,14 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx // bob has status of 1 pending swap - bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] assert(bobStatus.size == 1) assert(bobStatus.head.swapId === swapId) @@ -124,7 +127,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { test("swap in - claim by coop, receiver does not have sufficient channel balance") { f => import f._ - val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val (aliceSwap, bobSwap) = swapActors(alice, bob) val shortChannelId = connectNodes(alice, bob) // swap more satoshis than alice has available in the channel to send to bob @@ -136,7 +139,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -144,13 +147,13 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(openingTx.txOut.head.amount == amount + premium) // bob has status of 1 pending swap - bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] assert(bobStatus.size == 1) assert(bobStatus.head.swapId === swapId) // alice has status of 1 pending swap - alice.swapRegister ! ListPendingSwaps(aliceSwap.cli.ref) + aliceSwap.swapRegister ! ListPendingSwaps(aliceSwap.cli.ref.toTyped) val aliceStatus = aliceSwap.cli.expectMsgType[Iterable[Status]] assert(aliceStatus.size == 1) assert(aliceStatus.head.swapId == swapId) @@ -179,7 +182,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { test("swap in - claim by csv, receiver does not pay after opening tx confirmed") { f => import f._ - val (_, bobSwap) = swapProbes(alice, bob) + val (aliceSwap, bobSwap) = swapActors(alice, bob) val shortChannelId = connectNodes(alice, bob) // bob must have enough on-chain balance to send @@ -191,7 +194,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -199,10 +202,10 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(openingTx.txOut.head.amount == amount + premium) // swap in receiver (alice) stops unexpectedly - alice.swapRegister ! Kill + aliceSwap.swapRegister.toClassic ! Kill // bob has status of 1 pending swap - bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] assert(bobStatus.size == 1) assert(bobStatus.head.swapId === swapId) @@ -221,7 +224,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { test("swap in - claim by coop, receiver cancels while waiting for opening tx to confirm") { f => import f._ - val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val (aliceSwap, bobSwap) = swapActors(alice, bob) val shortChannelId = connectNodes(alice, bob) // bob must have enough on-chain balance to send @@ -233,14 +236,14 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + premium // swap in sender (bob) requests a swap in with swap in receiver (alice) - bob.swapRegister ! SwapInRequested(bobSwap.cli.ref, amount, shortChannelId) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx is published, but NOT yet confirmed on-chain val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx // swap in receiver (alice) sends CoopClose before the opening tx has been confirmed on-chain - alice.swapRegister ! CancelSwapRequested(aliceSwap.cli.ref, swapId) + aliceSwap.swapRegister ! CancelSwapRequested(aliceSwap.cli.ref.toTyped, swapId) val claimByCoopEvent = aliceSwap.swapEvents.expectMsgType[ClaimByCoopOffered] assert(claimByCoopEvent.swapId == swapId) @@ -258,7 +261,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { test("swap out - claim by invoice") { f => import f._ - val (aliceSwap, bobSwap) = swapProbes(alice, bob) + val (aliceSwap, bobSwap) = swapActors(alice, bob) val shortChannelId = connectNodes(alice, bob) // bob must have enough on-chain balance to send @@ -270,7 +273,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { bob.wallet.confirmedBalance = amount + fee // swap out receiver (alice) requests a swap out with swap out sender (bob) - alice.swapRegister ! SwapOutRequested(aliceSwap.cli.ref, amount, shortChannelId) + aliceSwap.swapRegister ! SwapOutRequested(aliceSwap.cli.ref.toTyped, amount, shortChannelId) val swapId = aliceSwap.cli.expectMsgType[SwapOpened].swapId // swap out receiver (alice) sends a payment of `fee` to swap out sender (bob) @@ -282,7 +285,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(openingTx.txOut.head.amount == amount) // bob has status of 1 pending swap - bob.swapRegister ! ListPendingSwaps(bobSwap.cli.ref) + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] assert(bobStatus.size == 1) assert(bobStatus.head.swapId === swapId) @@ -304,4 +307,20 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(bobSwap.swapEvents.expectMsgType[ClaimByInvoicePaid].swapId == swapId) } + test("eclair forwards swap messages to the SwapRegister") { f => + + + val protocolVersion = 2 + val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" + val premium = 10 + val responderPubkey = randomKey().publicKey + + val swapInAgreement = SwapInAgreement(protocolVersion, swapId.toHex, responderPubkey.toString, premium) + + // TODO: add message to SwapRegister which forwards messages to channel peer + // alice.peer.send(peer, swapInAgreement) + //val messageReceived = alice.swapRegister.expectMsgType[MessageReceived] + //assert(messageReceived.message === swapInAgreement) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala similarity index 81% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala index 2cc57eb13f..e9b29e86aa 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} import akka.actor.typed.ActorRef @@ -31,17 +31,21 @@ import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} -import fr.acinq.eclair.swap.SwapTransactions.openingTxWeight +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.openingTxWeight +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapOutAgreementCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.SwapOutRequest import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} +import fr.acinq.eclair.wire.protocol.UnknownMessage import fr.acinq.eclair.{NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{BeforeAndAfterAll, Outcome} +import java.sql.DriverManager import scala.concurrent.duration._ // with BitcoindService @@ -57,7 +61,7 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId - val keyManager: SwapKeyManager = TestConstants.Alice.nodeParams.swapKeyManager + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) val makerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey val takerPrivkey: PrivateKey = PrivateKey(randomBytes32()) val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey @@ -69,6 +73,7 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory val scriptOut: Long = 0 val blindingKey: String = "" val request: SwapOutRequest = SwapOutRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, takerPubkey.toHex) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message override def withFixture(test: OneArgTest): Outcome = { val watcher = testKit.createTestProbe[ZmqWatcher.Command]() @@ -84,18 +89,20 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory val sender = testKit.createTestProbe[Any]() val swapEvents = testKit.createTestProbe[SwapEvent]() val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet)), "swap-out-receiver") + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-out-receiver") withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) - test("happy path for new swap out") { f => + test("happy path for new swap out receiver") { f => import f._ // start new SwapInSender @@ -103,7 +110,7 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory monitor.expectMessage(StartSwapOutReceiver(request)) // SwapInSender:SwapOutAgreement -> SwapInReceiver - val agreement = register.expectMessageType[ForwardShortId[SwapOutAgreement]].message + val agreement = swapOutAgreementCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value assert(agreement.pubkey == makerPubkey.toHex) // SwapInReceiver pays the fee invoice @@ -117,8 +124,8 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory assert(openingTx.txOut.head.amount == amount) // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver - val openingTxBroadcasted = register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] - val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get + val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala similarity index 84% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala index 24ae6e9380..00e1ba1a53 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapOutSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} import akka.actor.typed.ActorRef @@ -33,17 +33,21 @@ import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} -import fr.acinq.eclair.swap.SwapCommands._ -import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} -import fr.acinq.eclair.swap.SwapResponses.{Status, SwapStatus} -import fr.acinq.eclair.swap.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.swapOutRequestCodec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} +import fr.acinq.eclair.wire.protocol.UnknownMessage import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{BeforeAndAfterAll, Outcome} +import java.sql.DriverManager import java.util.UUID import scala.concurrent.duration._ @@ -59,7 +63,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId - val keyManager: SwapKeyManager = TestConstants.Bob.nodeParams.swapKeyManager + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) val makerPrivkey: PrivateKey = PrivateKey(randomBytes32()) val takerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey @@ -75,6 +79,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l val scriptOut: Long = 0 val blindingKey: String = "" val request: SwapOutRequest = SwapOutRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message override def withFixture(test: OneArgTest): Outcome = { val watcher = testKit.createTestProbe[ZmqWatcher.Command]() @@ -90,18 +95,20 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l val sender = testKit.createTestProbe[Any]() val swapEvents = testKit.createTestProbe[SwapEvent]() val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) - val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet)), "swap-out-sender") + val swapOutSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-out-sender") - withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + withFixture(test.toNoArgTest(FixtureParam(swapOutSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) } case class FixtureParam(swapOutSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) - test("happy path for new swap out") { f => + test("happy path for new swap out sender") { f => import f._ // start new SwapOutSender @@ -109,7 +116,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l monitor.expectMessageType[StartSwapOutSender] // SwapOutSender: SwapOutRequest -> SwapOutReceiver - val request = register.expectMessageType[ForwardShortId[SwapOutRequest]].message + val request = swapOutRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value assert(request.pubkey == takerPubkey.toHex) // SwapOutReceiver: SwapOutAgreement -> SwapOutSender (request fee) @@ -165,7 +172,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l // SwapOutSender reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] - val claimByInvoiceTx = makeSwapClaimByInvoiceTx(request.amount.sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.hash, openingTxBroadcasted.scriptOut.toInt) + val claimByInvoiceTx = makeSwapClaimByInvoiceTx(request.amount.sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) swapOutSender ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) monitor.expectMessageType[ClaimTxConfirmed] swapEvents.expectMessageType[ClaimByInvoiceConfirmed] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala similarity index 62% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala index 319a295a44..563c37d09d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapRegisterSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} @@ -31,32 +31,41 @@ import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived, PaymentSent} -import fr.acinq.eclair.swap.SwapEvents.{ClaimByInvoiceConfirmed, ClaimByInvoicePaid, SwapEvent, TransactionPublished} -import fr.acinq.eclair.swap.SwapRegister.{MessageReceived, SwapInRequested, SwapTerminated} -import fr.acinq.eclair.swap.SwapResponses.{Response, SwapOpened} -import fr.acinq.eclair.swap.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, ClaimByInvoicePaid, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapRegister.{MessageReceived, SwapInRequested, SwapTerminated} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapInRequestCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion} -import org.mockito.scalatest.IdiomaticMockito +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, Outcome, ParallelTestExecution} import scodec.bits.HexStringSyntax +import java.sql.DriverManager import java.util.UUID import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} +import scala.language.postfixOps -class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with BeforeAndAfterAll with Matchers with FixtureAnyFunSuiteLike with IdiomaticMockito with ParallelTestExecution { +class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with BeforeAndAfterAll with Matchers with FixtureAnyFunSuiteLike with ParallelTestExecution { override implicit val timeout: Timeout = Timeout(30 seconds) val protocolVersion = 2 val noAsset = "" val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) val amount: Satoshi = 1000 sat val fee: Satoshi = 22 sat - val swapId0: String = ByteVector32.Zeroes.toHex - val swapId1: String = ByteVector32.One.toHex + + def paymentPreimage(index: Int): ByteVector32 = index match { + case 0 => ByteVector32.Zeroes + case 1 => ByteVector32.One + case _ => randomBytes32() + } + def swapId(index: Int): String = paymentPreimage(index).toHex val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId @@ -66,17 +75,22 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val blindingKey = "" val txId: String = ByteVector32.One.toHex + val aliceKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val aliceDb = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + + val bobKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + val bobDb = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + val aliceNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId - val alicePrivkey: PrivateKey = TestConstants.Alice.nodeParams.swapKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId0)).privateKey - val alicePubkey: PublicKey = alicePrivkey.publicKey - val bobPrivkey: PrivateKey = TestConstants.Alice.nodeParams.swapKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId1)).privateKey - val bobPubkey: PublicKey = bobPrivkey.publicKey - val paymentPreimage0: ByteVector32 = ByteVector32.Zeroes - val paymentPreimage1: ByteVector32 = ByteVector32.One - val invoice0: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage0), alicePrivkey, Left("PeerSwap payment invoice0"), CltvExpiryDelta(18)) - val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(paymentPreimage1), alicePrivkey, Left("PeerSwap fee invoice"), CltvExpiryDelta(18)) - val invoice1: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage1), alicePrivkey, Left("PeerSwap payment invoice1"), CltvExpiryDelta(18)) + def alicePrivkey(swapId: String): PrivateKey = aliceKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def alicePubkey(swapId: String): PublicKey = alicePrivkey(swapId).publicKey + def bobPrivkey(swapId: String): PrivateKey = bobKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def bobPubkey(swapId: String): PublicKey = bobPrivkey(swapId).publicKey + val invoice0: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage(0)), alicePrivkey(swapId(0)), Left("PeerSwap payment invoice0"), CltvExpiryDelta(18)) + val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(paymentPreimage(1)), bobPrivkey(swapId(1)), Left("PeerSwap fee invoice1"), CltvExpiryDelta(18)) + val invoice1: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage(1)), bobPrivkey(swapId(1)), Left("PeerSwap payment invoice1"), CltvExpiryDelta(18)) val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message override def withFixture(test: OneArgTest): Outcome = { val userCli = testKit.createTestProbe[Response]() @@ -100,60 +114,60 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app test("restore the swap register from the database") { f => import f._ - val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId0, noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey.toString()) - val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId0, bobPubkey.toString(), premium) - val swapOutRequest: SwapOutRequest = SwapOutRequest(protocolVersion, swapId1, noAsset, network, shortChannelId.toString, amount.toLong, bobPubkey.toString()) - val swapOutAgreement: SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId1, alicePubkey.toString(), feeInvoice.toString) - val openingTxBroadcasted0: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId0, invoice0.toString, txId, scriptOut, blindingKey) - val openingTxBroadcasted1: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId1, invoice1.toString, txId, scriptOut, blindingKey) + val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId(0), noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey(swapId(0)).toString()) + val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId(0), bobPubkey(swapId(0)).toString(), premium) + val swapOutRequest: SwapOutRequest = SwapOutRequest(protocolVersion, swapId(1), noAsset, network, shortChannelId.toString, amount.toLong, bobPubkey(swapId(1)).toString()) + val swapOutAgreement: SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId(1), bobPubkey(swapId(1)).toString(), feeInvoice.toString) + val openingTxBroadcasted0: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId(0), invoice0.toString, txId, scriptOut, blindingKey) + val openingTxBroadcasted1: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId(1), invoice1.toString, txId, scriptOut, blindingKey) val savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice0, openingTxBroadcasted0, swapRole = SwapRole.Maker, isInitiator = true), SwapData(swapOutRequest, swapOutAgreement, invoice1, openingTxBroadcasted1, swapRole = SwapRole.Taker, isInitiator = true)) - val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, savedData)), "SwapRegister") + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") // wait for SwapMaker and SwapTaker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() - // Taker: payment(paymentHash) -> Maker + // swapId0 - Taker: payment(paymentHash) -> Maker val paymentHash0 = Bolt11Invoice.fromString(openingTxBroadcasted0.payreq).get.paymentHash val paymentReceived0 = PaymentReceived(paymentHash0, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) testKit.system.eventStream ! Publish(paymentReceived0) - // SwapRegister received notice that SwapInSender swap completed + // swapId0 - SwapRegister received notice that SwapInSender swap completed val swap0Completed = swapEvents.expectMessageType[ClaimByInvoicePaid] - assert(swap0Completed.swapId === swapId0) + assert(swap0Completed.swapId === swapId(0)) - // SwapRegister receives notification that the swap Maker actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapId === swapId0) + // swapId0: SwapRegister receives notification that the swap Maker actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(0)) - // ZmqWatcher -> Taker, trigger confirmation of opening transaction - val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(swapOutRequest.amount.sat, alicePubkey, bobPubkey, invoice1.paymentHash)), 0) + // swapId1 - ZmqWatcher -> Taker, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(swapOutRequest.amount.sat, bobPubkey(swapId(1)), alicePubkey(swapId(1)), invoice1.paymentHash)), 0) watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx) - // wait for Taker to subscribe to PaymentEventReceived messages + // swapId1 - wait for Taker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() - // Taker validates the invoice and opening transaction before paying the invoice - testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice1.paymentHash, paymentPreimage1, amount.toMilliSatoshi, aliceNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + // swapId1 - Taker validates the invoice and opening transaction before paying the invoice + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice1.paymentHash, paymentPreimage(1), amount.toMilliSatoshi, aliceNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) - // ZmqWatcher -> Taker, trigger confirmation of claim-by-invoice transaction - val claimByInvoiceTx = makeSwapClaimByInvoiceTx(swapOutRequest.amount.sat, bobPubkey, alicePrivkey, paymentPreimage1, feeRatePerKw, openingTx.hash, 0) + // swapId1 - ZmqWatcher -> Taker, trigger confirmation of claim-by-invoice transaction + val claimByInvoiceTx = makeSwapClaimByInvoiceTx(swapOutRequest.amount.sat, bobPubkey(swapId(1)), alicePrivkey(swapId(1)), paymentPreimage(1), feeRatePerKw, openingTx.txid, 0) watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx) - // SwapRegister received notice that SwapOutSender completed + // swapId1 - SwapRegister received notice that SwapOutSender completed swapEvents.expectMessageType[TransactionPublished] - assert(swapEvents.expectMessageType[ClaimByInvoiceConfirmed].swapId === swapId1) + assert(swapEvents.expectMessageType[ClaimByInvoiceConfirmed].swapId === swapId(1)) - // SwapRegister receives notification that the swap Taker actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapId === swapId1) + // swapId1 - SwapRegister receives notification that the swap Taker actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(1)) testKit.stop(swapRegister) } - test("register a new swap in the swap register ") { f => + test("register a new swap in the swap register") { f => import f._ // initialize SwapRegister - val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, Set())), "SwapRegister") + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") swapEvents.expectNoMessage() userCli.expectNoMessage() @@ -163,21 +177,21 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app monitor.expectMessageType[SwapInRequested] // Alice:SwapInRequest -> Bob - val swapInRequest = register.expectMessageType[ForwardShortId[SwapInRequest]] - assert(swapId === swapInRequest.message.swapId) + val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + assert(swapId === swapInRequest.swapId) // Bob: SwapInAgreement -> Alice - swapRegister ! MessageReceived(SwapInAgreement(swapInRequest.message.protocolVersion, swapInRequest.message.swapId, bobPayoutPubkey.toString(), premium)) + swapRegister ! MessageReceived(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, bobPayoutPubkey.toString(), premium)) monitor.expectMessageType[MessageReceived] // SwapInSender confirms opening tx published swapEvents.expectMessageType[TransactionPublished] // Alice:OpeningTxBroadcasted -> Bob - val openingTxBroadcasted = register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] + val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value // Bob: payment(paymentHash) -> Alice - val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.message.payreq).get.paymentHash + val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get.paymentHash val paymentReceived = PaymentReceived(paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) testKit.system.eventStream ! Publish(paymentReceived) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/SwapsDbSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala similarity index 63% rename from eclair-core/src/test/scala/fr/acinq/eclair/db/SwapsDbSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala index 3b0eacf604..1ff74692a9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/SwapsDbSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala @@ -14,38 +14,34 @@ * limitations under the License. */ -package fr.acinq.eclair.db - +package fr.acinq.eclair.plugins.peerswap.db import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong} -import fr.acinq.eclair.TestDatabases.{TestPgDatabases, TestSqliteDatabases} -import fr.acinq.eclair.db.pg.PgSwapsDb -import fr.acinq.eclair.db.sqlite.SqliteSwapsDb import fr.acinq.eclair.payment.PaymentReceived.PartialPayment import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} -import fr.acinq.eclair.swap.SwapEvents.ClaimByInvoicePaid -import fr.acinq.eclair.swap.SwapRole.{Maker, SwapRole, Taker} -import fr.acinq.eclair.swap.{SwapData, SwapKeyManager} -import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiryDelta, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import fr.acinq.eclair.plugins.peerswap.SwapEvents.ClaimByInvoicePaid +import fr.acinq.eclair.plugins.peerswap.SwapRole.{Maker, SwapRole, Taker} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.{LocalSwapKeyManager, SwapData, SwapKeyManager} +import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, TestConstants, ToMilliSatoshiConversion, randomBytes32} import org.scalatest.funsuite.AnyFunSuite +import java.sql.DriverManager import java.util.concurrent.Executors import scala.concurrent.duration._ import scala.concurrent.{Await, ExecutionContext, ExecutionContextExecutor, Future} class SwapsDbSpec extends AnyFunSuite { - import fr.acinq.eclair.TestDatabases.forAllDbs - val protocolVersion = 2 val noAsset = "" - val network: String = TestConstants.Alice.nodeParams.chainHash.toString() + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) val amount: Satoshi = 1000 sat val fee: Satoshi = 100 sat - val makerKeyManager: SwapKeyManager = TestConstants.Alice.nodeParams.swapKeyManager - val takerKeyManager: SwapKeyManager = TestConstants.Bob.nodeParams.swapKeyManager + val makerKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val takerKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey val premium = 10 val txid: String = ByteVector32.One.toHex @@ -78,52 +74,45 @@ class SwapsDbSpec extends AnyFunSuite { } test("init database two times in a row") { - forAllDbs { - case sqlite: TestSqliteDatabases => - new SqliteSwapsDb(sqlite.connection) - new SqliteSwapsDb(sqlite.connection) - case pg: TestPgDatabases => - new PgSwapsDb()(pg.datasource) - new PgSwapsDb()(pg.datasource) - } + val connection = DriverManager.getConnection("jdbc:sqlite::memory:") + new SqliteSwapsDb(connection) + new SqliteSwapsDb(connection) } test("add/list/addResult/restore/remove swaps") { - forAllDbs { dbs => - val db = dbs.swaps + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + assert(db.list().isEmpty) - val swap_1 = swapData(randomBytes32().toString(),isInitiator = true, Maker) - val swap_2 = swapData(randomBytes32().toString(),isInitiator = false, Maker) - val swap_3 = swapData(randomBytes32().toString(),isInitiator = true, Taker) - val swap_4 = swapData(randomBytes32().toString(),isInitiator = false, Taker) + val swap_1 = swapData(randomBytes32().toString(),isInitiator = true, Maker) + val swap_2 = swapData(randomBytes32().toString(),isInitiator = false, Maker) + val swap_3 = swapData(randomBytes32().toString(),isInitiator = true, Taker) + val swap_4 = swapData(randomBytes32().toString(),isInitiator = false, Taker) - assert(db.list().toSet == Set.empty) - db.add(swap_1) - assert(db.list().toSet == Set(swap_1)) - db.add(swap_1) // duplicate is ignored - assert(db.list().size == 1) - db.add(swap_2) - db.add(swap_3) - db.add(swap_4) - assert(db.list().toSet == Set(swap_1, swap_2, swap_3, swap_4)) - db.addResult(paymentCompleteResult(swap_2.request.swapId)) - assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) - db.remove(swap_2.request.swapId) - assert(db.list().toSet == Set(swap_1, swap_3, swap_4)) - assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) - } + assert(db.list().toSet == Set.empty) + db.add(swap_1) + assert(db.list().toSet == Set(swap_1)) + db.add(swap_1) // duplicate is ignored + assert(db.list().size == 1) + db.add(swap_2) + db.add(swap_3) + db.add(swap_4) + assert(db.list().toSet == Set(swap_1, swap_2, swap_3, swap_4)) + db.addResult(paymentCompleteResult(swap_2.request.swapId)) + assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) + db.remove(swap_2.request.swapId) + assert(db.list().toSet == Set(swap_1, swap_3, swap_4)) + assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) } test("concurrent swap updates") { - forAllDbs { dbs => - val db = dbs.swaps - implicit val ec: ExecutionContextExecutor = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(8)) - val futures = for (_ <- 0 until 2500) yield { - Future(db.add(swapData(randomBytes32().toString(),isInitiator = true, Maker))) - } - val res = Future.sequence(futures) - Await.result(res, 60 seconds) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + assert(db.list().isEmpty) + + implicit val ec: ExecutionContextExecutor = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(8)) + val futures = for (_ <- 0 until 2500) yield { + Future(db.add(swapData(randomBytes32().toString(),isInitiator = true, Maker))) } + val res = Future.sequence(futures) + Await.result(res, 60 seconds) } - } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapJsonSerializersSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializersSpec.scala similarity index 95% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapJsonSerializersSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializersSpec.scala index 0737687b03..86b163c3bb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapJsonSerializersSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializersSpec.scala @@ -14,17 +14,14 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap.json -import fr.acinq.eclair.json.PeerSwapJsonSerializers.formats -import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.PeerSwapSpec +import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers.formats +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import org.json4s.jackson.JsonMethods.{compact, parse, render} import org.json4s.jackson.Serialization -/** - * Created by remyers on 03/30/2022. - */ - class PeerSwapJsonSerializersSpec extends PeerSwapSpec { test("encode/decode SwapInRequest to/from json") { val json = s"""{"protocol_version":$protocolVersion,"swap_id":"${swapId.toHex}","asset":"$asset","network":"$network","scid":"$shortId","amount":$amount,"pubkey":"$pubkey"}""".stripMargin diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala similarity index 95% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala index 0e14d6d432..f58b495225 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap.transactions import akka.actor.typed.ActorRef import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps} @@ -30,9 +30,9 @@ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.publish.FinalTxPublisher import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext -import fr.acinq.eclair.swap.SwapTransactions._ +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.{SwapClaimByCoopTx, SwapClaimByCsvTx, SwapClaimByInvoiceTx, checkSpendable} +import fr.acinq.eclair.transactions.Transactions.checkSpendable import grizzled.slf4j.Logging import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike @@ -40,10 +40,6 @@ import org.scalatest.funsuite.AnyFunSuiteLike import java.util.UUID import scala.concurrent.ExecutionContext.Implicits.global -/** - * Created by remyers on 06/05/2022. - */ - class SwapTransactionsSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with BeforeAndAfterAll with Logging { val makerRefundPriv: PrivateKey = PrivateKey(randomBytes32()) val takerPaymentPriv: PrivateKey = PrivateKey(randomBytes32()) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala similarity index 97% rename from eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala rename to plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala index b8c952c0e4..15c7eb8bef 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/swap/PeerSwapMessageCodecsSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala @@ -14,16 +14,12 @@ * limitations under the License. */ -package fr.acinq.eclair.swap +package fr.acinq.eclair.plugins.peerswap.wire.protocol -import fr.acinq.eclair.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback -import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.PeerSwapSpec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback import scodec.bits.HexStringSyntax -/** - * Created by remyers on 30/03/2022. - */ - class PeerSwapMessageCodecsSpec extends PeerSwapSpec { test("encode/decode SwapInRequest messages to/from binary") { diff --git a/pom.xml b/pom.xml index ce714f5b7b..5be9073ac0 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ eclair-core eclair-front eclair-node + plugins/peerswap A scala implementation of the Lightning Network From d3118f4164f79133deee646955b1319d3c8aa651 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 26 Oct 2022 14:27:40 +0200 Subject: [PATCH 20/23] Create random seed file if seed file not found --- .../plugins/peerswap/PeerSwapPlugin.scala | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala index 5292ed05cd..e798360e92 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala @@ -29,7 +29,7 @@ import fr.acinq.eclair.db.sqlite.SqliteUtils import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status} import fr.acinq.eclair.plugins.peerswap.db.SwapsDb import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb -import fr.acinq.eclair.{CustomFeaturePlugin, Feature, InitFeature, Kit, NodeFeature, NodeParams, Plugin, PluginParams, RouteProvider, Setup, ShortChannelId} +import fr.acinq.eclair.{CustomFeaturePlugin, Feature, InitFeature, Kit, NodeFeature, NodeParams, Plugin, PluginParams, RouteProvider, Setup, ShortChannelId, randomBytes32} import grizzled.slf4j.Logging import scodec.bits.ByteVector @@ -69,10 +69,17 @@ class PeerSwapPlugin extends Plugin with RouteProvider with Logging { val chainDir = new File(setup.datadir, chain) db = new SqliteSwapsDb(SqliteUtils.openSqliteFile(chainDir, "peer-swap.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "normal")) - // load seed - val seedFilename: String = "swap_seed.dat" - val seedPath: File = new File(setup.datadir, seedFilename) - val swapSeed: ByteVector = ByteVector(Files.readAllBytes(seedPath.toPath)) + // load or generate seed + val seedPath: File = new File(setup.datadir, "swap_seed.dat") + val swapSeed: ByteVector = if (seedPath.exists()) { + logger.info(s"use seed file: ${seedPath.getCanonicalPath}") + ByteVector(Files.readAllBytes(seedPath.toPath)) + } else { + val randomSeed = randomBytes32() + Files.write(seedPath.toPath, randomSeed.toArray) + logger.info(s"create new seed file: ${seedPath.getCanonicalPath}") + randomSeed.bytes + } swapKeyManager = new LocalSwapKeyManager(swapSeed, NodeParams.hashFromChain(chain)) } From c34a722b2b0e6c857750e17dd7db033c912f3044 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 26 Oct 2022 14:36:59 +0200 Subject: [PATCH 21/23] Clean up handling/responses for 'listswaps' and 'cancelswap' --- .../eclair/plugins/peerswap/ApiHandlers.scala | 2 +- .../plugins/peerswap/ApiSerializers.scala | 44 +++++++++++++++++++ .../plugins/peerswap/StatusAggregator.scala | 39 ++++++++++++++++ .../plugins/peerswap/SwapRegister.scala | 17 +++---- .../plugins/peerswap/SwapResponses.scala | 14 +++--- .../plugins/peerswap/ApiHandlersSpec.scala | 5 +++ 6 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiSerializers.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlersSpec.scala diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala index 330f6d0184..c010c75f3b 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala @@ -25,7 +25,7 @@ import fr.acinq.eclair.api.serde.FormParamExtractors._ object ApiHandlers { import fr.acinq.eclair.api.serde.JsonSupport.{marshaller, serialization} - import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers.formats + import fr.acinq.eclair.plugins.peerswap.ApiSerializers.formats def registerRoutes(kit: PeerSwapKit, eclairDirectives: EclairDirectives): Route = { import eclairDirectives._ diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiSerializers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiSerializers.scala new file mode 100644 index 0000000000..ca27b9d86f --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiSerializers.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import fr.acinq.eclair.json.MinimalSerializer +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers +import org.json4s.{Formats, JField, JObject, JString} + +object ApiSerializers { + + object SwapStatusSerializer extends MinimalSerializer({ + case x: SwapStatus => JObject(List( + JField("swap_id", JString(x.swapId)), + JField("actor", JString(x.actor)), + JField("behavior", JString(x.behavior)), + JField("request", JString(x.request.json)), + JField("agreement", JString(x.agreement_opt.collect(a => a.json).toString)), + JField("invoice", JString(x.invoice_opt.toString)), + JField("openingTxBroadcasted", JString(x.openingTxBroadcasted_opt.collect(o => o.json).toString)) + )) + }) + + object SwapResponseSerializer extends MinimalSerializer({ + case x: Response => JString(x.toString) + }) + + implicit val formats: Formats = PeerSwapJsonSerializers.formats + SwapResponseSerializer + SwapStatusSerializer + +} diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala new file mode 100644 index 0000000000..5b78156b07 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.Status + +object StatusAggregator { + def apply(swapsCount: Int, replyTo: ActorRef[Iterable[Status]]): Behavior[Status] = Behaviors.setup { context => + new StatusAggregator(context, swapsCount, replyTo).waiting(Set()) + } +} + +private class StatusAggregator(context: ActorContext[Status], swapsCount: Int, replyTo: ActorRef[Iterable[Status]]) { + private def waiting(statuses: Set[Status]): Behavior[Status] = { + Behaviors.receiveMessage[Status] { + case s: Status if statuses.size + 1 == swapsCount => + replyTo ! (statuses + s) + Behaviors.stopped + case s: Status => + waiting(statuses + s) + } + } +} \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala index 2ee0b3c0e1..d052d6138b 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala @@ -19,7 +19,6 @@ package fr.acinq.eclair.plugins.peerswap import akka.actor import akka.actor.typed import akka.actor.typed.ActorRef.ActorRefOps -import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.adapter.TypedActorRefOps import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy} @@ -29,15 +28,13 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.io.UnknownMessageReceived import fr.acinq.eclair.plugins.peerswap.SwapCommands._ import fr.acinq.eclair.plugins.peerswap.SwapRegister.Command -import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status, SwapNotFound, SwapOpened} import fr.acinq.eclair.plugins.peerswap.db.SwapsDb import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest} import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} import scodec.Attempt -import scala.concurrent.duration.DurationInt -import scala.concurrent.{Await, Future} import scala.reflect.ClassTag object SwapRegister { @@ -155,16 +152,20 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam case SwapTerminated(swapId) => registering(swaps - swapId) case ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) => - // TODO: is this the best way to do this?! - val statuses: Iterable[Future[Status]] = swaps.values.map(swap => swap.ask(ref => GetStatus(ref))(1000 milliseconds, context.system.scheduler)) - replyTo ! statuses.map(v => Await.result(v, 1000 milliseconds)) + if (swaps.nonEmpty) { + val aggregator = context.spawn(StatusAggregator(swaps.size, replyTo), s"status-aggregator") + swaps.values.foreach(swap => swap ! GetStatus(aggregator)) + } else { + replyTo ! Seq[Status]() + } Behaviors.same case CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) => swaps.get(swapId) match { case Some(swap) => swap ! CancelRequested(replyTo) Behaviors.same - case None => context.log.error(s"could not cancel swap $swapId: does not exist") + case None => context.log.info(s"could not cancel swap $swapId: not found") + replyTo ! SwapNotFound(swapId) Behaviors.same } } diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala index b90ce77540..a70a9c8237 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala @@ -27,12 +27,16 @@ object SwapResponses { sealed trait Success extends Response + case class SwapOpened(swapId: String) extends Success { + override def toString: String = s"swap $swapId opened successfully." + } + sealed trait Fail extends Response sealed trait Error extends Fail - case class SwapOpened(swapId: String) extends Success { - override def toString: String = s"swap $swapId opened successfully." + case class SwapNotFound(swapId: String) extends Fail { + override def toString: String = s"swap $swapId not found." } case class UserCanceled(swapId: String) extends Fail { @@ -51,12 +55,8 @@ object SwapResponses { override def toString: String = s"swap $swapId canceled due to invalid message during $behavior: $message." } - case class LocalError(swapId: String, t: Throwable) extends Error { - override def toString: String = s"swap $swapId local error: $t." - } - case class SwapError(swapId: String, reason: String) extends Error { - override def toString: String = s"swap $swapId swap error: $reason." + override def toString: String = s"swap $swapId error: $reason." } case class InternalError(swapId: String, reason: String) extends Error { diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlersSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlersSpec.scala new file mode 100644 index 0000000000..8e46f7b8ee --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlersSpec.scala @@ -0,0 +1,5 @@ +package fr.acinq.eclair.plugins.peerswap + +// TODO: how do we test the call / response behavior of the API ? + +// TODO: test that a response serialization exception does not crash the node ? From 4da5074149262ad90878b11f85323eab6dc59b59 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 26 Oct 2022 14:41:07 +0200 Subject: [PATCH 22/23] Add some TODOs to discuss later --- .../fr/acinq/eclair/plugins/peerswap/SwapTaker.scala | 3 ++- .../acinq/eclair/plugins/peerswap/PeerSwapSpec.scala | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala index 753c90857d..2bb88c5468 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala @@ -152,6 +152,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, } private def awaitAgreement(request: SwapOutRequest): Behavior[SwapCommand] = { + // TODO: why do we not get a ForwardFailure message when channel is not connected? sendShortId(register, shortChannelId)(request) receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { @@ -296,7 +297,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) watchForPayment(watch = false) // unsubscribe from payment event notifications - commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") + commitClaim(wallet)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { case ClaimTxCommitted => Behaviors.same diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala index 3c0502f850..ab12f95854 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala @@ -58,4 +58,14 @@ class PeerSwapSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("applica NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, db, blockCount, feeEstimator) } + test("load swap key from file") { + // TODO + } + + test( "create swap key if none exists") { + // TODO + } + + // TODO: test that a plugin exception does not crash the node ? restarts the plugin? + } From 805e602987c14099c241d993a39c6f2a8c1bda73 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 26 Oct 2022 23:37:23 +0200 Subject: [PATCH 23/23] Cleanup register and prevent multiple swaps per channel --- .../plugins/peerswap/StatusAggregator.scala | 7 +- .../eclair/plugins/peerswap/SwapHelpers.scala | 15 +- .../eclair/plugins/peerswap/SwapMaker.scala | 1 - .../plugins/peerswap/SwapRegister.scala | 134 +++++++++--------- .../plugins/peerswap/SwapResponses.scala | 4 + .../plugins/peerswap/SwapRegisterSpec.scala | 104 ++++++++++---- 6 files changed, 160 insertions(+), 105 deletions(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala index 5b78156b07..c199e2098e 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala @@ -22,7 +22,12 @@ import fr.acinq.eclair.plugins.peerswap.SwapResponses.Status object StatusAggregator { def apply(swapsCount: Int, replyTo: ActorRef[Iterable[Status]]): Behavior[Status] = Behaviors.setup { context => - new StatusAggregator(context, swapsCount, replyTo).waiting(Set()) + if (swapsCount == 0) { + replyTo ! Seq() + Behaviors.stopped + } else { + new StatusAggregator(context, swapsCount, replyTo).waiting(Set()) + } } } diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala index 4dfdfba580..f2d744912f 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala @@ -89,20 +89,19 @@ object SwapHelpers { def paymentEventAdapter(context: ActorContext[SwapCommand]): ActorRef[PaymentEvent] = context.messageAdapter[PaymentEvent](PaymentEventReceived) - def sendShortId(register: actor.ActorRef, shortChannelId: ShortChannelId)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = { + def makeUnknownMessage(message: HasSwapId): UnknownMessage = { val encoded = peerSwapMessageCodecWithFallback.encode(message).require - val unknownMessage = UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.toByteVector) - register ! Register.ForwardShortId(forwardShortIdAdapter(context), shortChannelId, unknownMessage) + UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.toByteVector) } + def sendShortId(register: actor.ActorRef, shortChannelId: ShortChannelId)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.ForwardShortId(forwardShortIdAdapter(context), shortChannelId, makeUnknownMessage(message)) + def forwardShortIdAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortIdFailure[UnknownMessage]] = context.messageAdapter[Register.ForwardShortIdFailure[UnknownMessage]](ForwardShortIdFailureAdapter) - def send(register: actor.ActorRef, channelId: ByteVector32)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = { - val encoded = peerSwapMessageCodecWithFallback.encode(message).require - val unknownMessage = UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.toByteVector) - register ! Register.Forward(forwardAdapter(context), channelId, unknownMessage) - } + def send(register: actor.ActorRef, channelId: ByteVector32)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.Forward(forwardAdapter(context), channelId, makeUnknownMessage(message)) def forwardAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[UnknownMessage]] = context.messageAdapter[Register.ForwardFailure[UnknownMessage]](ForwardFailureAdapter) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala index cae2594889..8b4fd7b13f 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala @@ -217,7 +217,6 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, receiveSwapMessage[CreateOpeningTxMessages](context, "createOpeningTx") { case InvoiceResponse(invoice: Bolt11Invoice) => fundOpening(wallet, feeRatePerKw)((request.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) Behaviors.same - // TODO: checkpoint PersistentSwapData for this swap to a database before committing the opening tx case OpeningTxFunded(invoice, fundingResponse) => commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") Behaviors.same diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala index d052d6138b..bb59b21e04 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala @@ -28,10 +28,10 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.io.UnknownMessageReceived import fr.acinq.eclair.plugins.peerswap.SwapCommands._ import fr.acinq.eclair.plugins.peerswap.SwapRegister.Command -import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status, SwapNotFound, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapResponses._ import fr.acinq.eclair.plugins.peerswap.db.SwapsDb import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec -import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest, SwapRequest} import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} import scodec.Attempt @@ -44,11 +44,16 @@ object SwapRegister { def replyTo: ActorRef[Response] } + sealed trait SwapRequested extends ReplyToMessages { + def replyTo: ActorRef[Response] + def amount: Satoshi + def shortChannelId: ShortChannelId + } + sealed trait RegisteringMessages extends Command - case class PluginMessageReceived(message: UnknownMessageReceived) extends RegisteringMessages - case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages - case class SwapOutRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with ReplyToMessages - case class MessageReceived(message: HasSwapId) extends RegisteringMessages + case class WrappedUnknownMessageReceived(message: UnknownMessageReceived) extends RegisteringMessages + case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with SwapRequested + case class SwapOutRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with SwapRequested case class SwapTerminated(swapId: String) extends RegisteringMessages case class ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) extends RegisteringMessages case class CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) extends RegisteringMessages with ReplyToMessages @@ -62,6 +67,8 @@ object SwapRegister { private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, data: Set[SwapData]) { import SwapRegister._ + case class SwapEntry(shortChannelId: String, swap: ActorRef[SwapCommands.SwapCommand]) + private def myReceive[B <: Command : ClassTag](stateName: String)(f: B => Behavior[Command]): Behavior[Command] = Behaviors.receiveMessage[Command] { case m: B => f(m) @@ -70,6 +77,12 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam context.log.error(s"received unhandled message while in state $stateName of ${m.getClass.getSimpleName}") Behaviors.same } + private def watchForUnknownMessage(watch: Boolean)(implicit context: ActorContext[Command]): Unit = + if (watch) context.system.classicSystem.eventStream.subscribe(unknownMessageReceivedAdapter(context).toClassic, classOf[UnknownMessageReceived]) + else context.system.classicSystem.eventStream.unsubscribe(unknownMessageReceivedAdapter(context).toClassic, classOf[UnknownMessageReceived]) + private def unknownMessageReceivedAdapter(context: ActorContext[Command]): ActorRef[UnknownMessageReceived] = { + context.messageAdapter[UnknownMessageReceived](WrappedUnknownMessageReceived) + } private def initializing: Behavior[Command] = { val swaps = data.map { state => @@ -83,24 +96,18 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam } context.watchWith(swap, SwapTerminated(state.request.swapId)) swap ! RestoreSwap(state) - state.request.swapId -> swap.unsafeUpcast + state.request.swapId -> SwapEntry(state.request.scid, swap.unsafeUpcast) }.toMap registering(swaps) } - def watchForUnknownMessage(watch: Boolean)(implicit context: ActorContext[Command]): Unit = - if (watch) context.system.classicSystem.eventStream.subscribe(unknownMessageAdapter(context).toClassic, classOf[UnknownMessageReceived]) - else context.system.classicSystem.eventStream.unsubscribe(unknownMessageAdapter(context).toClassic, classOf[UnknownMessageReceived]) - - def unknownMessageAdapter(context: ActorContext[Command]): ActorRef[UnknownMessageReceived] = { - context.messageAdapter[UnknownMessageReceived](PluginMessageReceived) - } - - private def registering(swaps: Map[String, ActorRef[SwapCommands.SwapCommand]]): Behavior[Command] = { - // TODO: fail requests for swaps on a channel if one already exists for the channel; keep a list of channels with active swaps - // TODO: check currently registered swaps, and swap db, to prevent reuse of a swapId + private def registering(swaps: Map[String, SwapEntry]): Behavior[Command] = { watchForUnknownMessage(watch = true)(context) myReceive[RegisteringMessages]("registering") { + case swapRequested: SwapRequested if swaps.exists( p => p._2.shortChannelId == swapRequested.shortChannelId.toCoordinatesString ) => + // ignore swap requests for channels with ongoing swaps + swapRequested.replyTo ! SwapExistsForChannel("", swapRequested.shortChannelId.toCoordinatesString) + Behaviors.same case SwapInRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) @@ -108,8 +115,7 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapInSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) - registering(swaps + (swapId -> swap)) - + registering(swaps + (swapId -> SwapEntry(shortChannelId.toCoordinatesString, swap))) case SwapOutRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) @@ -117,56 +123,52 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapOutSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) - registering(swaps + (swapId -> swap)) - - case MessageReceived(request: SwapInRequest) => - val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) - .onFailure(SupervisorStrategy.restart), "Swap-"+ request.scid) - context.watchWith(swap, SwapTerminated(request.swapId)) - swap ! StartSwapInReceiver(request) - registering(swaps + (request.swapId -> swap)) - - case MessageReceived(request: SwapOutRequest) => - val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) - .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) - context.watchWith(swap, SwapTerminated(request.swapId)) - swap ! StartSwapOutReceiver(request) - registering(swaps + (request.swapId -> swap)) - - case PluginMessageReceived(unknownMessageReceived) => - if (PeerSwapPlugin.peerSwapTags.contains(unknownMessageReceived.message.tag)) { - peerSwapMessageCodec.decode(unknownMessageReceived.message.data.toBitVector) match { - case Attempt.Successful(m) => context.self ! MessageReceived(m.value) - case _ => context.log.error(s"could not decode peerswap message $unknownMessageReceived") - } - } - Behaviors.same - - case MessageReceived(msg) => swaps.get(msg.swapId) match { - case Some(swap) => swap ! SwapMessageReceived(msg) - Behaviors.same - case None => context.log.error(s"received unhandled message for swap ${msg.swapId}: $msg") - Behaviors.same - } - - case SwapTerminated(swapId) => registering(swaps - swapId) - + registering(swaps + (swapId -> SwapEntry(shortChannelId.toCoordinatesString, swap))) case ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) => - if (swaps.nonEmpty) { - val aggregator = context.spawn(StatusAggregator(swaps.size, replyTo), s"status-aggregator") - swaps.values.foreach(swap => swap ! GetStatus(aggregator)) - } else { - replyTo ! Seq[Status]() - } + val aggregator = context.spawn(StatusAggregator(swaps.size, replyTo), s"status-aggregator") + swaps.values.foreach(e => e.swap ! GetStatus(aggregator)) Behaviors.same - case CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) => swaps.get(swapId) match { - case Some(swap) => swap ! CancelRequested(replyTo) - Behaviors.same - case None => context.log.info(s"could not cancel swap $swapId: not found") - replyTo ! SwapNotFound(swapId) - Behaviors.same + case Some(e) => e.swap ! CancelRequested(replyTo) + case None => replyTo ! SwapNotFound(swapId) + } + Behaviors.same + case SwapTerminated(swapId) => + registering(swaps - swapId) + case WrappedUnknownMessageReceived(unknownMessageReceived) => + if (PeerSwapPlugin.peerSwapTags.contains(unknownMessageReceived.message.tag)) { + peerSwapMessageCodec.decode(unknownMessageReceived.message.data.toBitVector) match { + case Attempt.Successful(decodedMessage) => decodedMessage.value match { + case swapRequest: SwapRequest if swaps.exists(s => s._2.shortChannelId == swapRequest.scid) => + // ignore swap requests for channels with active swaps + Behaviors.same + case request: SwapInRequest => + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) + .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) + context.watchWith(swap, SwapTerminated(request.swapId)) + swap ! StartSwapInReceiver(request) + registering(swaps + (request.swapId -> SwapEntry(request.scid, swap))) + case request: SwapOutRequest => + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) + .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) + context.watchWith(swap, SwapTerminated(request.swapId)) + swap ! StartSwapOutReceiver(request) + registering(swaps + (request.swapId -> SwapEntry(request.scid, swap))) + case msg: HasSwapId => swaps.get(msg.swapId) match { + // handle all other swap messages + case Some(e) => e.swap ! SwapMessageReceived(msg) + Behaviors.same + case None => context.log.error(s"received unhandled swap message: $msg") + Behaviors.same + } + } + case _ => context.log.error(s"could not decode unknown message received: $unknownMessageReceived") + Behaviors.same + } + } else { + // unknown message received without a peerswap message tag + Behaviors.same } } } diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala index a70a9c8237..651a1fab8f 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala @@ -35,6 +35,10 @@ object SwapResponses { sealed trait Error extends Fail + case class SwapExistsForChannel(swapId: String, shortChannelId: String) extends Fail { + override def toString: String = s"swap $swapId already exists for channel $shortChannelId" + } + case class SwapNotFound(swapId: String) extends Fail { override def toString: String = s"swap $swapId not found." } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala index 563c37d09d..bbfad86598 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala @@ -30,10 +30,12 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} import fr.acinq.eclair.channel.DATA_NORMAL import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.io.UnknownMessageReceived import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived, PaymentSent} import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, ClaimByInvoicePaid, SwapEvent, TransactionPublished} -import fr.acinq.eclair.plugins.peerswap.SwapRegister.{MessageReceived, SwapInRequested, SwapTerminated} -import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapHelpers.makeUnknownMessage +import fr.acinq.eclair.plugins.peerswap.SwapRegister.{SwapInRequested, SwapOutRequested, SwapTerminated, WrappedUnknownMessageReceived} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, SwapExistsForChannel, SwapOpened} import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapInRequestCodec} @@ -41,6 +43,7 @@ import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec import fr.acinq.eclair.wire.protocol.UnknownMessage import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import org.scalatest.concurrent.PatienceConfiguration import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, Outcome, ParallelTestExecution} @@ -59,13 +62,6 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) val amount: Satoshi = 1000 sat val fee: Satoshi = 22 sat - - def paymentPreimage(index: Int): ByteVector32 = index match { - case 0 => ByteVector32.Zeroes - case 1 => ByteVector32.One - case _ => randomBytes32() - } - def swapId(index: Int): String = paymentPreimage(index).toHex val channelData: DATA_NORMAL = ChannelCodecsSpec.normal val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get val channelId: ByteVector32 = channelData.channelId @@ -74,22 +70,35 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val scriptOut = 0 val blindingKey = "" val txId: String = ByteVector32.One.toHex - val aliceKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) val aliceDb = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) - val bobKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) val bobDb = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) - val aliceNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId + val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(paymentPreimage(1)), bobPrivkey(swapId(1)), Left("PeerSwap fee invoice 1"), CltvExpiryDelta(18)) + val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId(0), noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey(swapId(0)).toString()) + val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId(0), bobPubkey(swapId(0)).toString(), premium) + val swapOutRequest: SwapOutRequest = SwapOutRequest(protocolVersion, swapId(1), noAsset, network, shortChannelId.toString, amount.toLong, bobPubkey(swapId(1)).toString()) + val swapOutAgreement: SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId(1), bobPubkey(swapId(1)).toString(), feeInvoice.toString) + + def paymentPreimage(index: Int): ByteVector32 = index match { + case 0 => ByteVector32.Zeroes + case 1 => ByteVector32.One + case _ => randomBytes32() + } + def privKey(index: Int): PrivateKey = index match { + case 0 => alicePrivkey(swapId(0)) + case _ => bobPrivkey(swapId(index)) + } + def swapId(index: Int): String = paymentPreimage(index).toHex def alicePrivkey(swapId: String): PrivateKey = aliceKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey def alicePubkey(swapId: String): PublicKey = alicePrivkey(swapId).publicKey def bobPrivkey(swapId: String): PrivateKey = bobKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey def bobPubkey(swapId: String): PublicKey = bobPrivkey(swapId).publicKey - val invoice0: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage(0)), alicePrivkey(swapId(0)), Left("PeerSwap payment invoice0"), CltvExpiryDelta(18)) - val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(paymentPreimage(1)), bobPrivkey(swapId(1)), Left("PeerSwap fee invoice1"), CltvExpiryDelta(18)) - val invoice1: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage(1)), bobPrivkey(swapId(1)), Left("PeerSwap payment invoice1"), CltvExpiryDelta(18)) - val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + def invoice(index: Int): Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage(index)), privKey(index), Left(s"PeerSwap payment invoice $index"), CltvExpiryDelta(18)) + def openingTxBroadcasted(index: Int): OpeningTxBroadcasted = OpeningTxBroadcasted(swapId(index), invoice(index).toString, txId, scriptOut, blindingKey) + def makePluginMessage(message: HasSwapId): WrappedUnknownMessageReceived = WrappedUnknownMessageReceived(UnknownMessageReceived(null, alicePubkey(""), makeUnknownMessage(message), null)) def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message override def withFixture(test: OneArgTest): Outcome = { @@ -114,21 +123,15 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app test("restore the swap register from the database") { f => import f._ - val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId(0), noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey(swapId(0)).toString()) - val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId(0), bobPubkey(swapId(0)).toString(), premium) - val swapOutRequest: SwapOutRequest = SwapOutRequest(protocolVersion, swapId(1), noAsset, network, shortChannelId.toString, amount.toLong, bobPubkey(swapId(1)).toString()) - val swapOutAgreement: SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId(1), bobPubkey(swapId(1)).toString(), feeInvoice.toString) - val openingTxBroadcasted0: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId(0), invoice0.toString, txId, scriptOut, blindingKey) - val openingTxBroadcasted1: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId(1), invoice1.toString, txId, scriptOut, blindingKey) - val savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice0, openingTxBroadcasted0, swapRole = SwapRole.Maker, isInitiator = true), - SwapData(swapOutRequest, swapOutAgreement, invoice1, openingTxBroadcasted1, swapRole = SwapRole.Taker, isInitiator = true)) + val savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice(0), openingTxBroadcasted(0), swapRole = SwapRole.Maker, isInitiator = true), + SwapData(swapOutRequest, swapOutAgreement, invoice(1), openingTxBroadcasted(1), swapRole = SwapRole.Taker, isInitiator = true)) val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") // wait for SwapMaker and SwapTaker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() // swapId0 - Taker: payment(paymentHash) -> Maker - val paymentHash0 = Bolt11Invoice.fromString(openingTxBroadcasted0.payreq).get.paymentHash + val paymentHash0 = Bolt11Invoice.fromString(openingTxBroadcasted(0).payreq).get.paymentHash val paymentReceived0 = PaymentReceived(paymentHash0, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) testKit.system.eventStream ! Publish(paymentReceived0) @@ -140,14 +143,14 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(0)) // swapId1 - ZmqWatcher -> Taker, trigger confirmation of opening transaction - val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(swapOutRequest.amount.sat, bobPubkey(swapId(1)), alicePubkey(swapId(1)), invoice1.paymentHash)), 0) + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(swapOutRequest.amount.sat, bobPubkey(swapId(1)), alicePubkey(swapId(1)), invoice(1).paymentHash)), 0) watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx) // swapId1 - wait for Taker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() // swapId1 - Taker validates the invoice and opening transaction before paying the invoice - testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice1.paymentHash, paymentPreimage(1), amount.toMilliSatoshi, aliceNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice(1).paymentHash, paymentPreimage(1), amount.toMilliSatoshi, aliceNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) // swapId1 - ZmqWatcher -> Taker, trigger confirmation of claim-by-invoice transaction val claimByInvoiceTx = makeSwapClaimByInvoiceTx(swapOutRequest.amount.sat, bobPubkey(swapId(1)), alicePrivkey(swapId(1)), paymentPreimage(1), feeRatePerKw, openingTx.txid, 0) @@ -180,9 +183,17 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value assert(swapId === swapInRequest.swapId) + // Alice's database has no items before the opening tx is published + assert(aliceDb.list().isEmpty) + // Bob: SwapInAgreement -> Alice - swapRegister ! MessageReceived(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, bobPayoutPubkey.toString(), premium)) - monitor.expectMessageType[MessageReceived] + swapRegister ! makePluginMessage(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, bobPayoutPubkey.toString(), premium)) + monitor.expectMessageType[WrappedUnknownMessageReceived] + + // Alice's database should be updated before the opening tx is published + eventually(PatienceConfiguration.Timeout(2 seconds), PatienceConfiguration.Interval(1 second)) { + assert(aliceDb.list().size == 1) + } // SwapInSender confirms opening tx published swapEvents.expectMessageType[TransactionPublished] @@ -204,4 +215,39 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app testKit.stop(swapRegister) } + test("fail second swap request on same channel") { f => + import f._ + + // initialize SwapRegister + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") + swapEvents.expectNoMessage() + userCli.expectNoMessage() + + // first swap request succeeds + swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + val response = userCli.expectMessageType[SwapOpened] + val request = swapInRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + assert(response.swapId === request.swapId) + + // subsequent swap requests with same channel id from the user or peer should fail + swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + userCli.expectMessageType[SwapExistsForChannel] + register.expectNoMessage() + + swapRegister ! SwapOutRequested(userCli.ref, amount, shortChannelId) + userCli.expectMessageType[SwapExistsForChannel] + register.expectNoMessage() + + swapRegister ! makePluginMessage(swapInRequest) + register.expectNoMessage() + + swapRegister ! makePluginMessage(swapOutRequest) + register.expectNoMessage() + } + + test("list the active swap in the register") { f => + + + + } }