From 0a894a0f4829d5b3e09b9038ea442d88eb86b3a3 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 18 Nov 2022 11:27:10 +0100 Subject: [PATCH 01/32] Add ability to send a custom lightning messages from a loaded plugin --- .../src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala | 5 +++++ .../scala/fr/acinq/eclair/transactions/Transactions.scala | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) 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 51e7c93e0f..38a4cf8a91 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 @@ -1648,6 +1648,11 @@ 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) + + // forward unknown messages that originate from loaded plugins + case Event(unknownMsg: UnknownMessage, _) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => + send(unknownMsg) + stay() } onTransition { 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..f34f43af78 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 @@ -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 From c84a15fcade14166efeef35836a61a019196b24e Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 8 Nov 2022 10:22:53 +0100 Subject: [PATCH 02/32] Fix UnknownMessage to interop with CLN PeerSwap; breaks other Eclair plugins --- .../fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2ec01393ae..db6004a6be 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 @@ -417,7 +417,7 @@ object LightningMessageCodecs { val unknownMessageCodec: Codec[UnknownMessage] = ( ("tag" | uint16) :: - ("message" | varsizebinarydata) + ("message" | bytes) ).as[UnknownMessage] val lightningMessageCodec = discriminated[LightningMessage].by(uint16) From 5565badec96ec300fc1de7fadf9a9ef14953c219 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 18 Nov 2022 15:25:41 +0100 Subject: [PATCH 03/32] Fix ChannelUpdate message without type to not parse as an UnknownMessage --- .../scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala index 7732398452..1dc6bf7c8b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala @@ -20,7 +20,7 @@ import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.eclair.crypto.Mac32 import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.failureMessageCodec -import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelFlagsCodec, channelUpdateCodec, messageFlagsCodec, meteredLightningMessageCodec} +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelFlagsCodec, channelUpdateCodec, messageFlagsCodec, lightningMessageCodec} import fr.acinq.eclair.{BlockHeight, CltvExpiry, MilliSatoshi, MilliSatoshiLong, UInt64} import scodec.bits.ByteVector import scodec.codecs._ @@ -90,7 +90,7 @@ object FailureMessageCodecs { val NODE = 0x2000 val UPDATE = 0x1000 - val channelUpdateCodecWithType = meteredLightningMessageCodec.narrow[ChannelUpdate](f => Attempt.successful(f.asInstanceOf[ChannelUpdate]), g => g) + val channelUpdateCodecWithType = lightningMessageCodec.narrow[ChannelUpdate](f => Attempt.successful(f.asInstanceOf[ChannelUpdate]), g => g) // NB: for historical reasons some implementations were including/omitting the message type (258 for ChannelUpdate) // this codec supports both versions for decoding, and will encode with the message type From 364f81ca4bf00e622ccd21973302dd7303c55678 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 18 Nov 2022 15:52:14 +0100 Subject: [PATCH 04/32] Update to use metered solution for channelUpdateCodecWithType --- .../fr/acinq/eclair/wire/protocol/FailureMessage.scala | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala index 1dc6bf7c8b..31f361f00e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala @@ -20,11 +20,11 @@ import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.eclair.crypto.Mac32 import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.failureMessageCodec -import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelFlagsCodec, channelUpdateCodec, messageFlagsCodec, lightningMessageCodec} +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelFlagsCodec, channelUpdateCodec, messageFlagsCodec, meteredLightningMessageCodec} import fr.acinq.eclair.{BlockHeight, CltvExpiry, MilliSatoshi, MilliSatoshiLong, UInt64} import scodec.bits.ByteVector import scodec.codecs._ -import scodec.{Attempt, Codec} +import scodec.{Attempt, Codec, Err} /** * see https://github.com/lightningnetwork/lightning-rfc/blob/master/04-onion-routing.md @@ -90,7 +90,10 @@ object FailureMessageCodecs { val NODE = 0x2000 val UPDATE = 0x1000 - val channelUpdateCodecWithType = lightningMessageCodec.narrow[ChannelUpdate](f => Attempt.successful(f.asInstanceOf[ChannelUpdate]), g => g) + val channelUpdateCodecWithType = meteredLightningMessageCodec.narrow[ChannelUpdate]({ + case f: ChannelUpdate => Attempt.successful(f) + case _ => Attempt.failure(Err("not a ChanelUpdate message")) + }, g => g) // NB: for historical reasons some implementations were including/omitting the message type (258 for ChannelUpdate) // this codec supports both versions for decoding, and will encode with the message type From 8606defdd703896982cd024185fa4ffa9bf6b021 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 24 Nov 2022 14:07:48 +0100 Subject: [PATCH 05/32] Address comments from t-bast - revert unsealing trait - add unit test for forwarding unknown messages --- .../acinq/eclair/transactions/Transactions.scala | 2 +- .../fr/acinq/eclair/channel/ChannelDataSpec.scala | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) 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 f34f43af78..1deb5395ff 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 @@ -100,7 +100,7 @@ object Transactions { case object Remote extends TxOwner } - trait TransactionWithInputInfo { + sealed trait TransactionWithInputInfo { def input: InputInfo def desc: String def tx: Transaction diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala index e86490470a..8da5b198c9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala @@ -22,8 +22,9 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchFundingSpentTriggered import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.ChannelStateTestsBase +import fr.acinq.eclair.io.Peer.OutgoingMessage import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.{CommitSig, RevokeAndAck, UnknownNextPeer, UpdateAddHtlc} +import fr.acinq.eclair.wire.protocol.{CommitSig, RevokeAndAck, UnknownMessage, UnknownNextPeer, UpdateAddHtlc} import fr.acinq.eclair.{MilliSatoshiLong, NodeParams, TestKitBaseClass} import org.scalatest.funsuite.AnyFunSuiteLike import scodec.bits.ByteVector @@ -615,4 +616,15 @@ class ChannelDataSpec extends TestKitBaseClass with AnyFunSuiteLike with Channel } } + test("send UnknownMessage to peer if tag registered by a plugin") { + val unknownMessage = UnknownMessage(60003, ByteVector32.One) + val setup = init() + reachNormal(setup) + import setup._ + awaitCond(alice.stateName == NORMAL) + alicePeer.receiveN(5) + alice ! unknownMessage + assert(alicePeer.expectMsgType[OutgoingMessage].msg == unknownMessage) + } + } From a6e65270af9e79dc7cbd6c7d2279183817fdb9ac Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 29 Nov 2022 14:15:08 +0100 Subject: [PATCH 06/32] Change to send unknown messages via the switchboard instead of the register --- .../fr/acinq/eclair/channel/fsm/Channel.scala | 5 ----- .../src/main/scala/fr/acinq/eclair/io/Peer.scala | 7 +++++++ .../scala/fr/acinq/eclair/io/Switchboard.scala | 10 +++++++++- .../fr/acinq/eclair/channel/ChannelDataSpec.scala | 14 +------------- .../test/scala/fr/acinq/eclair/io/PeerSpec.scala | 11 ++++++++++- .../scala/fr/acinq/eclair/io/SwitchboardSpec.scala | 14 +++++++++++++- 6 files changed, 40 insertions(+), 21 deletions(-) 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 38a4cf8a91..51e7c93e0f 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 @@ -1648,11 +1648,6 @@ 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) - - // forward unknown messages that originate from loaded plugins - case Event(unknownMsg: UnknownMessage, _) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => - send(unknownMsg) - stay() } onTransition { 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 0ff0feabfd..f681152591 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 @@ -304,6 +304,11 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA context.system.eventStream.publish(UnknownMessageReceived(self, remoteNodeId, unknownMsg, d.connectionInfo)) stay() + case Event(RelayUnknownMessage(unknownMsg: UnknownMessage), d: ConnectedData) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => + logMessage(unknownMsg, "OUT") + d.peerConnection forward unknownMsg + stay() + case Event(unhandledMsg: LightningMessage, _) => log.warning("ignoring message {}", unhandledMsg) stay() @@ -571,6 +576,8 @@ object Peer { case class ConnectionDown(peerConnection: ActorRef) extends RemoteTypes case class RelayOnionMessage(messageId: ByteVector32, msg: OnionMessage, replyTo_opt: Option[typed.ActorRef[Status]]) + + case class RelayUnknownMessage(unknownMessage: UnknownMessage) // @formatter:on def makeChannelParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], defaultFinalScriptPubkey: ByteVector, walletStaticPaymentBasepoint: Option[PublicKey], isInitiator: Boolean, dualFunded: Boolean, fundingAmount: Satoshi): LocalParams = { 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..da9870dd86 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,7 +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.wire.protocol.OnionMessage +import fr.acinq.eclair.wire.protocol.{OnionMessage, UnknownMessage} import fr.acinq.eclair.{SubscriptionsComplete, NodeParams} /** @@ -115,6 +115,12 @@ class Switchboard(nodeParams: NodeParams, peerFactory: Switchboard.PeerFactory) case RelayMessage(messageId, prevNodeId, nextNodeId, dataToRelay, relayPolicy, replyTo) => val relay = context.spawn(Behaviors.supervise(MessageRelay()).onFailure(typed.SupervisorStrategy.stop), s"relay-message-$messageId") relay ! MessageRelay.RelayMessage(messageId, self, prevNodeId.getOrElse(nodeParams.nodeId), nextNodeId, dataToRelay, relayPolicy, replyTo) + + case ForwardUnknownMessage(remoteNodeId, msg) => + getPeer(remoteNodeId) match { + case Some(peer) => peer ! Peer.RelayUnknownMessage(msg) + case None => log.error(s"Peer $remoteNodeId not found, could not forward unknown message: $msg") + } } /** @@ -169,6 +175,8 @@ object Switchboard { case class RouterPeerConf(routerConf: RouterConf, peerConf: PeerConnection.Conf) extends RemoteTypes case class RelayMessage(messageId: ByteVector32, prevNodeId: Option[PublicKey], nextNodeId: PublicKey, message: OnionMessage, relayPolicy: RelayPolicy, replyTo_opt: Option[typed.ActorRef[MessageRelay.Status]]) + + case class ForwardUnknownMessage(nodeId: PublicKey, msg: UnknownMessage) // @formatter:on } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala index 8da5b198c9..e86490470a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelDataSpec.scala @@ -22,9 +22,8 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchFundingSpentTriggered import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.ChannelStateTestsBase -import fr.acinq.eclair.io.Peer.OutgoingMessage import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.{CommitSig, RevokeAndAck, UnknownMessage, UnknownNextPeer, UpdateAddHtlc} +import fr.acinq.eclair.wire.protocol.{CommitSig, RevokeAndAck, UnknownNextPeer, UpdateAddHtlc} import fr.acinq.eclair.{MilliSatoshiLong, NodeParams, TestKitBaseClass} import org.scalatest.funsuite.AnyFunSuiteLike import scodec.bits.ByteVector @@ -616,15 +615,4 @@ class ChannelDataSpec extends TestKitBaseClass with AnyFunSuiteLike with Channel } } - test("send UnknownMessage to peer if tag registered by a plugin") { - val unknownMessage = UnknownMessage(60003, ByteVector32.One) - val setup = init() - reachNormal(setup) - import setup._ - awaitCond(alice.stateName == NORMAL) - alicePeer.receiveN(5) - alice ! unknownMessage - assert(alicePeer.expectMsgType[OutgoingMessage].msg == unknownMessage) - } - } 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 903fa3f014..002e8a8253 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 @@ -20,7 +20,7 @@ import akka.actor.Status.Failure import akka.actor.{ActorContext, ActorRef, ActorSystem, FSM, PoisonPill, Status} import akka.testkit.{TestFSMRef, TestKit, TestProbe} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{Block, Btc, SatoshiLong, Script} +import fr.acinq.bitcoin.scalacompat.{Block, Btc, ByteVector32, SatoshiLong, Script} import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features._ import fr.acinq.eclair.TestConstants._ @@ -616,6 +616,15 @@ class PeerSpec extends FixtureSpec { peer ! RelayOnionMessage(messageId, msg, Some(probe.ref.toTyped)) probe.expectMsg(MessageRelay.Disconnected(messageId)) } + + test("send UnknownMessage to peer if tag registered by a plugin") { f => + import f._ + val probe = TestProbe() + val unknownMessage = UnknownMessage(60003, ByteVector32.One) + connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal)) + probe.send(peer, Peer.RelayUnknownMessage(unknownMessage)) + peerConnection.expectMsgType[UnknownMessage] + } } object PeerSpec { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala index c20f33814b..11e7ae57fd 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala @@ -3,7 +3,7 @@ package fr.acinq.eclair.io import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps import akka.actor.{Actor, ActorContext, ActorRef, Props, Status} import akka.testkit.{TestActorRef, TestProbe} -import fr.acinq.bitcoin.scalacompat.ByteVector64 +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.TestConstants._ import fr.acinq.eclair.channel.ChannelIdAssigned @@ -138,6 +138,18 @@ class SwitchboardSpec extends TestKitBaseClass with AnyFunSuiteLike { peer.expectMsg(Peer.GetPeerInfo(Some(probe.ref.toTyped))) } + test("forward UnknownMessage to peer") { + val unknownMessage = UnknownMessage(60003, ByteVector32.One) + val (probe, peer) = (TestProbe(), TestProbe()) + val switchboard = TestActorRef(new Switchboard(Alice.nodeParams, FakePeerFactory(probe, peer))) + val knownPeerNodeId = randomKey().publicKey + probe.send(switchboard, Peer.Connect(knownPeerNodeId, None, probe.ref, isPersistent = true)) + peer.expectMsgType[Peer.Init] + peer.expectMsgType[Peer.Connect] + probe.send(switchboard, ForwardUnknownMessage(knownPeerNodeId, unknownMessage)) + peer.expectMsg(Peer.RelayUnknownMessage(unknownMessage)) + } + } object SwitchboardSpec { From ec4886e273c90c2d64258b08d6f9cacfe599a18a Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 10:48:40 +0200 Subject: [PATCH 07/32] Add serialization for peerswap messages --- .../json/PeerSwapJsonSerializers.scala | 104 ++++++++++++++ .../wire/protocol/PeerSwapMessageCodecs.scala | 92 ++++++++++++ .../json/PeerSwapJsonSerializersSpec.scala | 89 ++++++++++++ .../protocol/PeerSwapMessageCodecsSpec.scala | 131 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializers.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecs.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializersSpec.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializers.scala new file mode 100644 index 0000000000..6f473d1d40 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializers.scala @@ -0,0 +1,104 @@ +/* + * 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.json + +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} + +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/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecs.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecs.scala new file mode 100644 index 0000000000..057ce7578e --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecs.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.plugins.peerswap.wire.protocol + +import fr.acinq.eclair.KamonExt +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._ +import org.json4s.jackson.JsonMethods._ +import org.json4s.jackson.Serialization +import scodec.bits.BitVector +import scodec.codecs._ +import scodec.{Attempt, Codec} + +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[HasSwapId, Int] = discriminated[HasSwapId].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[HasSwapId] = discriminatorWithDefault(peerSwapMessageCodec, unknownPeerSwapMessageCodec.upcast) + + 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() + 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/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializersSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializersSpec.scala new file mode 100644 index 0000000000..86b163c3bb --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/json/PeerSwapJsonSerializersSpec.scala @@ -0,0 +1,89 @@ +/* + * 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.json + +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 + +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/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala new file mode 100644 index 0000000000..294a7b8cc9 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala @@ -0,0 +1,131 @@ +/* + * 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.PeerSwapSpec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{lightningMessageCodecWithFallback, unknownMessageCodec} +import fr.acinq.eclair.wire.protocol.UnknownMessage +import scodec.bits.HexStringSyntax + +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"a4557b2270726f746f636f6c5f76657273696f6e223a332c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + 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"a4577b2270726f746f636f6c5f76657273696f6e223a332c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + 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"a4597b2270726f746f636f6c5f76657273696f6e223a332c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c227072656d69756d223a313030307d" + 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"a45b7b2270726f746f636f6c5f76657273696f6e223a332c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c22706179726571223a22696e766f6963652068657265227d" + 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) + } + + test("decode/encode cln message") { + val bin = hex"0xa4557b2270726f746f636f6c5f76657273696f6e223a332c22737761705f6964223a2233616533613661656531633635616564386266626437343034386163616230333739396230353464306438373462616630383739643231343665316431333637222c226e6574776f726b223a2272656774657374222c226173736574223a22222c2273636964223a223934373178317830222c22616d6f756e74223a3130303030302c227075626b6579223a22303362303433643261396265306566343462666266653861626639333932393330306336663733373737666232316263646366656638626664613161333136646265227d" + val peerswap_decoded = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(peerswap_decoded.value.isInstanceOf[SwapInRequest]) + + val lightning_decoded = lightningMessageCodecWithFallback.decode(bin.bits).require + val unknownMessage = lightning_decoded.value.asInstanceOf[UnknownMessage] + peerSwapMessageCodecWithFallback.decode(unknownMessageCodec.encode(unknownMessage).require) + + val lightning_encoded = lightningMessageCodecWithFallback.encode(unknownMessage).require + assert(lightning_encoded.toByteVector == bin) + } + +} From e153d23ced040591170fae0d14871f811847dd61 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 10:51:14 +0200 Subject: [PATCH 08/32] Add peerswap on-chain transactions --- .../peerswap/transactions/SwapScripts.scala | 53 ++++++ .../transactions/SwapTransactions.scala | 163 ++++++++++++++++++ .../transactions/SwapTransactionsSpec.scala | 102 +++++++++++ 3 files changed, 318 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapScripts.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapScripts.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapScripts.scala new file mode 100644 index 0000000000..c1833f3e0d --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapScripts.scala @@ -0,0 +1,53 @@ +package fr.acinq.eclair.plugins.peerswap.transactions + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, OP_CHECKSEQUENCEVERIFY, OP_CHECKSIG, OP_ELSE, OP_ENDIF, OP_EQUALVERIFY, OP_NOTIF, OP_PUSHDATA, OP_SHA256, OP_SIZE, ScriptElt, ScriptWitness} +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) +} diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala new file mode 100644 index 0000000000..de58f78ffd --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala @@ -0,0 +1,163 @@ +/* + * 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.transactions + +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._ +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import SwapScripts._ +import fr.acinq.eclair.transactions.Scripts.der +import fr.acinq.eclair.transactions.Transactions.{InputInfo, TransactionWithInputInfo, weight2fee} +import scodec.bits.ByteVector + +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 + */ + 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 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) + val openingTxOut = makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash) + InputInfo(OutPoint(fundingTxId.reverse, 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 { + // TODO: should check that locktime is set in the past, or ideally not set at all + case Transaction(2, _, txOut, _) 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.reverse, 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.reverse, 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.reverse, 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/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala new file mode 100644 index 0000000000..f58b495225 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala @@ -0,0 +1,102 @@ +/* + * 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.transactions + +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.plugins.peerswap.transactions.SwapTransactions._ +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions.checkSpendable +import grizzled.slf4j.Logging +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuiteLike + +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global + +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 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) + + 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 + 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.txid, openingTxOut, amount + premium, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + + 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 + premium, makerRefundPriv, takerPaymentPriv, paymentHash, feeratePerKw, openingTx.txid, openingTxOut) + assert(swapClaimByCoopTx.txIn.head.sequence == 0) + assert(checkSpendable(SwapClaimByCoopTx(inputInfo, swapClaimByCoopTx)).isSuccess) + + 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) + } +} \ No newline at end of file From 84f5b2e9db5e1b9c9da323914ed7ca2b1518dfb5 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 10:52:54 +0200 Subject: [PATCH 09/32] Add peerswap shared helper functions and data structures --- .../plugins/peerswap/SwapCommands.scala | 98 +++++++++++ .../eclair/plugins/peerswap/SwapData.scala | 32 ++++ .../eclair/plugins/peerswap/SwapEvents.scala | 46 +++++ .../eclair/plugins/peerswap/SwapHelpers.scala | 161 ++++++++++++++++++ .../plugins/peerswap/SwapResponses.scala | 76 +++++++++ .../wire/protocol/PeerSwapMessageTypes.scala | 70 ++++++++ 6 files changed, 483 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageTypes.scala diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala new file mode 100644 index 0000000000..e41ebaadbf --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala @@ -0,0 +1,98 @@ +/* + * 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.ActorRef +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.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 { + + sealed trait SwapCommand + + // @formatter:off + case class StartSwapInSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand + case class StartSwapOutReceiver(request: SwapOutRequest) extends SwapCommand + case class RestoreSwap(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 + 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 AwaitFeePaymentMessages with AwaitOpeningTxConfirmedMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages + case class ForwardFailureAdapter(result: Register.ForwardFailure[UnknownMessage]) 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 with ClaimSwapCoopMessages + 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 with AwaitFeePaymentMessages with PayFeeInvoiceMessages + + 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 class StartSwapInReceiver(request: SwapInRequest) extends SwapCommand + case class StartSwapOutSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand + + sealed trait SendAgreementMessages extends SwapCommand + sealed trait AwaitFeePaymentMessages extends SwapCommand + case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[UnknownMessage]) extends AwaitFeePaymentMessages with SendCoopCloseMessages with SendAgreementMessages + + sealed trait PayClaimInvoiceMessages extends SwapCommand + + sealed trait SendCoopCloseMessages extends SwapCommand + case class OpeningTxOutputSpent(openingTxOutputSpentTriggered: WatchOutputSpentTriggered) extends SendCoopCloseMessages + + sealed trait ClaimSwapMessages extends SwapCommand + + sealed trait PayFeeInvoiceMessages extends SwapCommand + + sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages 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/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala new file mode 100644 index 0000000000..4baae92e55 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala @@ -0,0 +1,32 @@ +/* + * 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.payment.Bolt11Invoice +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: 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, result: String = "") + +object SwapData { +} diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala new file mode 100644 index 0000000000..eef6bca5b0 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala @@ -0,0 +1,46 @@ +/* + * 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.bitcoin.scalacompat.Transaction +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.payment.PaymentReceived + +object SwapEvents { + sealed trait SwapEvent { + def swapId: String + } + + case class Canceled(swapId: String, reason: String) extends SwapEvent + case class TransactionPublished(swapId: String, tx: Transaction, desc: String) extends SwapEvent + case class ClaimByInvoiceConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent{ + override def toString: String = s"Claimed by paid invoice: $confirmation" + } + case class ClaimByCoopOffered(swapId: String, reason: String) extends SwapEvent { + override def toString: String = s"Coop close offered to peer: $reason" + } + case class ClaimByInvoicePaid(swapId: String, payment: PaymentReceived) extends SwapEvent { + override def toString: String = s"Invoice payment received: $payment" + } + case class ClaimByCoopConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { + override def toString: String = s"Claimed by coop: $confirmation" + } + case class ClaimByCsvConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { + override def toString: String = s"Claimed by csv: $confirmation" + } + +} 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 new file mode 100644 index 0000000000..f5f8d758af --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala @@ -0,0 +1,161 @@ +/* + * 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 +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, 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.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.UnknownMessage +import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond, randomBytes32} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.reflect.ClassTag +import scala.util.{Failure, Success, Try} + +object SwapHelpers { + + 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.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}") + Behaviors.receiveMessage { + case m: B => context.log.debug(s"$stateName: processing message $m") + f(m) + case m => context.log.error(s"$stateName: received unhandled message $m") + Behaviors.same + } + } + + 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) + + 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()) + + 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 makeUnknownMessage(message: HasSwapId): UnknownMessage = { + val encoded = peerSwapMessageCodecWithFallback.encode(message).require + UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.drop(16).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 = + register ! Register.Forward(forwardAdapter(context), channelId, makeUnknownMessage(message)) + + 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 + val openingTx = makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, invoice.paymentHash) + // funding successful, commit the opening tx + 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") + } + } + + 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) => 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)) + } + } + + def commitClaim(wallet: OnChainWallet)(swapId: String, txInfo: TransactionWithInputInfo, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = + checkSpendable(txInfo) match { + case Success(_) => + // 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) => 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.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 = + context.pipeToSelf(wallet.rollback(tx)) { + 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/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 new file mode 100644 index 0000000000..651a1fab8f --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala @@ -0,0 +1,76 @@ +/* + * 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.payment.Bolt11Invoice +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapAgreement, SwapRequest} + +object SwapResponses { + + sealed trait Response { + def swapId: String + } + + 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 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." + } + + case class UserCanceled(swapId: String) extends Fail { + override def toString: String = s"swap $swapId canceled by user." + } + + 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 { + 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." + } + + case class SwapError(swapId: String, reason: String) extends Error { + override def toString: String = s"swap $swapId error: $reason." + } + + 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 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/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..28a038739c --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageTypes.scala @@ -0,0 +1,70 @@ +/* + * 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.bitcoin.scalacompat.ByteVector32 +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 { + def txid: ByteVector32 = ByteVector32.fromValidHex(txId) +} + +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" +} From 72f8e3a23e5b6ff5ea458470362cf821d44df7b5 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 10:54:46 +0200 Subject: [PATCH 10/32] Add peerswap key manager --- .../peerswap/LocalSwapKeyManager.scala | 84 +++++++++++++++++++ .../plugins/peerswap/SwapKeyManager.scala | 57 +++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/LocalSwapKeyManager.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapKeyManager.scala diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/LocalSwapKeyManager.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/LocalSwapKeyManager.scala new file mode 100644 index 0000000000..0fafb2d965 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/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.plugins.peerswap + +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/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapKeyManager.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapKeyManager.scala new file mode 100644 index 0000000000..c72bcfe3c5 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/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.plugins.peerswap + +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())) + } +} + From f2e7349bfc76f6d6bb2cb76cddc581f0873994f2 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 10:57:04 +0200 Subject: [PATCH 11/32] Add peerswap db support --- .../plugins/peerswap/db/DualSwapsDb.scala | 55 ++++++++ .../eclair/plugins/peerswap/db/SwapsDb.scala | 83 ++++++++++++ .../plugins/peerswap/db/pg/PgSwapsDb.scala | 100 +++++++++++++++ .../peerswap/db/sqlite/SqliteSwapsDb.scala | 87 +++++++++++++ .../plugins/peerswap/db/SwapsDbSpec.scala | 119 ++++++++++++++++++ 5 files changed, 444 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala 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/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala new file mode 100644 index 0000000000..badf272dbe --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala @@ -0,0 +1,83 @@ +/* + * 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 fr.acinq.eclair.payment.Bolt11Invoice +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 + +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 result = rs.getString("result") + 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"), + result) + } +} \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala new file mode 100644 index 0000000000..8b265b5a8a --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/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.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.pg.PgUtils.PgLock.NoLock.withLock +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 + +object PgSwapsDb { + val DB_NAME = "swaps" + val CURRENT_VERSION = 1 +} + +class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { + + import fr.acinq.eclair.db.pg.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/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala new file mode 100644 index 0000000000..5bf480d0ac --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/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.plugins.peerswap.db.sqlite + +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +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 + +object SqliteSwapsDb { + val DB_NAME = "swaps" + val CURRENT_VERSION = 1 +} + +class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { + + import fr.acinq.eclair.db.sqlite.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/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala new file mode 100644 index 0000000000..b43bcc5ad6 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala @@ -0,0 +1,119 @@ +/* + * 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 fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong} +import fr.acinq.eclair.payment.PaymentReceived.PartialPayment +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +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 { + + val protocolVersion = 3 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val fee: Satoshi = 100 sat + 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 + 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") { + val connection = DriverManager.getConnection("jdbc:sqlite::memory:") + new SqliteSwapsDb(connection) + new SqliteSwapsDb(connection) + } + + test("add/list/addResult/restore/remove 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) + + 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.restore().size == 1) + 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_3.request.swapId) + assert(db.list().size == 3) // include resolved swap_2 + assert(db.restore().toSet == Set(swap_1, swap_4)) + } + + test("concurrent swap updates") { + 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 From c0ae6059e95e8eb9e3be22e9fa1675620b08e556 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 13:12:45 +0200 Subject: [PATCH 12/32] Add peerswap maker and tests --- .../eclair/plugins/peerswap/SwapMaker.scala | 355 ++++++++++++++++++ .../plugins/peerswap/SwapInSenderSpec.scala | 257 +++++++++++++ .../peerswap/SwapOutReceiverSpec.scala | 151 ++++++++ 3 files changed, 763 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala 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 new file mode 100644 index 0000000000..747d0461fb --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala @@ -0,0 +1,355 @@ +/* + * 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 +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.{WatchFundingDeeplyBuriedTriggered, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceiveStandardPayment} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +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.transactions.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 + +import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Success} + +object SwapMaker { + /* + SwapMaker SwapTaker + + "Swap Out" + RESPONDER INITIATOR + | | [createSwap] + | SwapOutRequest | + |<-------------------------------| + [validateRequest] | | [awaitAgreement] + | | + | SwapOutAgreement | + |------------------------------->| + [awaitFeePayment] | | [validateFeeInvoice] + | | + | | [payFeeInvoice] + |<------------------------------>| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Swap In" + 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, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommands.SwapCommand] = + Behaviors.setup { context => + Behaviors.receiveMessagePartial { + case StartSwapInSender(amount, swapId, shortChannelId) => + 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, 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, 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 + } + case AbortSwap => Behaviors.stopped + } + } +} + +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 = 3 + val noAsset = "" + implicit val timeout: Timeout = 30 seconds + 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(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, 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")) + 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 ! SwapStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) + Behaviors.same + } + } + } + + private def awaitAgreement(request: SwapInRequest): Behavior[SwapCommand] = { + sendShortId(register, shortChannelId)(request) + + receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { + case SwapMessageReceived(agreement: SwapInAgreement) if agreement.protocolVersion != protocolVersion => + swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) + 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(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)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) + Behaviors.same + } + } + + def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = { + val receivePayment = ReceiveStandardPayment(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.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) + Behaviors.same + case OpeningTxFunded(invoice, fundingResponse) => + commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") + Behaviors.same + case OpeningTxCommitted(invoice, openingTxBroadcasted) => + 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) + Behaviors.same + 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(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 ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + Behaviors.same + } + } + + 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 + sendShortId(register, shortChannelId)(openingTxBroadcasted) // send message to peer about opening tx broadcast + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { + // 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, isInitiator) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + } + + 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(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) + + watchForPayment(watch = false) + watchForTxConfirmation(watcher)(openingConfirmedAdapter, openingTxId, 1) // watch for opening tx to be confirmed + + receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { + case OpeningTxConfirmed(_) => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) + commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") + Behaviors.same + case ClaimTxCommitted => Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => + 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 ! SwapStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + + 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) + 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(_) => + claimSwapCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case StateTimeout => + // TODO: problem with the blockchain monitor? + Behaviors.same + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + + 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(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)(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(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(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCsv", 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.") + db.addResult(event) + Behaviors.stopped + } + + def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + 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") + case f: Fail => context.log.info(s"canceled swap: $f") + case _ => context.log.error(s"canceled swap $failure.swapId, reason: unknown.") + } + Behaviors.stopped + } + +} \ No newline at end of file diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala new file mode 100644 index 0000000000..d3af3c74fc --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -0,0 +1,257 @@ +/* + * 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.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, 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.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +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.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} + +// 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 = 3 + 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 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 + 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) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + 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, 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))) + } + + 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true) + db.add(swapData) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + register.expectMessageType[ForwardShortId[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 claim by invoice + swapEvents.expectMessageType[ClaimByInvoicePaid] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInSender) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Invoice payment received:")) + } + + test("happy path for new swap") { f => + import f._ + + // start new SwapInSender + swapInSender ! StartSwapInSender(amount, swapId, shortChannelId) + + // SwapInSender: SwapInRequest -> SwapInSender + val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + + // SwapInReceiver: SwapInAgreement -> SwapInSender + 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 = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val invoice = Bolt11Invoice.fromString(openingTxBroadcasted.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[SwapStatus].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)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByInvoicePaid] + + // wait for swap actor to stop + testKit.stop(swapInSender) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Invoice payment received:")) + } + + 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true) + db.add(swapData) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver: CoopClose -> SwapInSender + swapInSender ! SwapMessageReceived(CoopClose(swapId, "oops", takerPrivkey.toHex)) + + // 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) + assert(userCli.expectMessageType[SwapStatus].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) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Claimed by coop")) + } + + 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true) + db.add(swapData) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + + // 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[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) + 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)) + + // SwapInSender reports a successful csv close + swapEvents.expectMessageType[TransactionPublished] + swapEvents.expectMessageType[ClaimByCsvConfirmed] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } + +} diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala new file mode 100644 index 0000000000..034713ac67 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -0,0 +1,151 @@ +/* + * 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.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.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.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 +case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 3 + 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 = 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 + 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) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + 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]() + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + + // 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, 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 receiver") { f => + import f._ + + // start new SwapInSender + swapInSender ! StartSwapOutReceiver(request) + monitor.expectMessage(StartSwapOutReceiver(request)) + + // SwapInSender:SwapOutAgreement -> SwapInReceiver + val agreement = swapOutAgreementCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + 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 = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.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[SwapStatus].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) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Invoice payment received:")) + } +} From 283bf7b09d536ffc7347d950f9846ae34c72d648 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 13:15:01 +0200 Subject: [PATCH 13/32] Add peerswap taker and tests --- .../eclair/plugins/peerswap/SwapTaker.scala | 349 ++++++++++++++++++ .../plugins/peerswap/SwapInReceiverSpec.scala | 318 ++++++++++++++++ .../plugins/peerswap/SwapOutSenderSpec.scala | 185 ++++++++++ 3 files changed, 852 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala 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 new file mode 100644 index 0000000000..6fd4257fd0 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala @@ -0,0 +1,349 @@ +/* + * 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 +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, Satoshi, SatoshiLong, Transaction} +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.db.OutgoingPaymentStatus.{Failed, Pending, Succeeded} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentFailed, PaymentSent} +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.transactions.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.{CltvExpiryDelta, NodeParams, ShortChannelId, TimestampSecond, ToMilliSatoshiConversion} +import scodec.bits.ByteVector + +import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Success} + +object SwapTaker { + /* + SwapMaker SwapTaker + + "Swap Out" + RESPONDER INITIATOR + | | [createSwap] + | SwapOutRequest | + |<-------------------------------| + [validateRequest] | | [awaitAgreement] + | | + | SwapOutAgreement | + |------------------------------->| + [awaitFeePayment] | | [validateFeeInvoice] + | | + | | [payFeeInvoice] + |<------------------------------>| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Swap In" + 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, 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, 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, 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) => + val swap = new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) + // handle a payment that has already succeeded, failed or is still pending + nodeParams.db.payments.listOutgoingPayments(d.invoice.paymentHash).collectFirst { + case p if p.status.isInstanceOf[Succeeded] => swap.claimSwap(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, p.status.asInstanceOf[Succeeded].paymentPreimage, d.isInitiator) + case p if p.status.isInstanceOf[Failed] => swap.sendCoopClose(d.request, s"Lightning payment failed: ${p.status.asInstanceOf[Failed].failures}") + case p if p.status == Pending => swap.payClaimInvoice(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, d.isInitiator) + }.getOrElse( + // if payment was not yet sent, fail the swap + swap.sendCoopClose(d.request, s"Lightning payment not sent.") + ) + case Failure(e) => context.log.error(s"could not restore swap receiver with invalid shortChannelId: $d, $e") + Behaviors.stopped + } + case AbortSwap => Behaviors.stopped + } + } +} + +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 = 3 + val noAsset = "" + implicit val timeout: Timeout = 30 seconds + + 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? + 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] = { + // TODO: why do we not get a ForwardFailure message when channel is not connected? + 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(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)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapStatus(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(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) + swapCanceled(CreateFailed(request.swapId, s"Cancel requested by user while validating opening tx.")) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) + Behaviors.same + } + } + + 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(request.swapId, s"incompatible request: $request.")) + } else { + sendAgreement(request, SwapInAgreement(protocolVersion, request.swapId, takerPubkey(request.swapId).toHex, premium.toLong)) + } + } + + 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) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = false) + 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.")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user after sending agreement.") + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) + Behaviors.same + } + } + + 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(request, agreement, openingTxBroadcasted, opening.tx, isInitiator) + 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) + sendCoopClose(request, s"Cancel requested by user while waiting for opening tx to confirm.") + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { + Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { + case Failure(e) => sendCoopClose(request, s"Could not parse payreq: $e") + case Success(invoice) if invoice.amount_opt.isDefined && invoice.amount_opt.get > request.amount.sat.toMilliSatoshi => + sendCoopClose(request, s"Invoice amount ${invoice.amount_opt.get} > requested on-chain amount ${request.amount.sat.toMilliSatoshi}") + case Success(invoice) if invoice.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => + sendCoopClose(request, s"Channel hop other than $shortChannelId found in invoice hints ${invoice.routingInfo}") + case Success(invoice) if invoice.isExpired() => + sendCoopClose(request, s"Invoice is expired.") + case Success(invoice) if invoice.minFinalCltvExpiryDelta >= CltvExpiryDelta(claimByCsvDelta.toInt / 2) => + sendCoopClose(request, s"Invoice min-final-cltv-expiry delta too long.") + case Success(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => + // save restore point before a payment is initiated + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator)) + payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) + payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, isInitiator) + case Success(_) => + sendCoopClose(request, s"Invalid opening tx: $openingTx") + } + } + + def payClaimInvoice(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, isInitiator: Boolean): Behavior[SwapCommand] = { + watchForPayment(watch = true) // subscribe to payment event notifications + receiveSwapMessage[PayClaimInvoiceMessages](context, "payClaimInvoice") { + case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != invoice.paymentHash => Behaviors.same + case PaymentEventReceived(p: PaymentSent) => claimSwap(request, agreement, openingTxBroadcasted, invoice, p.paymentPreimage, isInitiator) + case PaymentEventReceived(p: PaymentFailed) => sendCoopClose(request, s"Lightning payment failed: $p") + case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(request, s"Lightning payment failed (invalid PaymentEvent received: $p).") + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user while paying claim invoice.") + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def claimSwap(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, isInitiator: Boolean): Behavior[SwapCommand] = { + val inputInfo = makeSwapOpeningInputInfo(openingTxBroadcasted.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, openingTxBroadcasted.txid, 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)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") + + receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { + case ClaimTxCommitted => Behaviors.same + 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 $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 $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(request.swapId, "Can not cancel swap after claim tx committed.") + Behaviors.same // ignore + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def sendCoopClose(request: SwapRequest, reason: String): 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)) + swapCompleted(ClaimByCoopOffered(request.swapId, reason)) + } + + def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(event) + context.log.info(s"completed swap: $event.") + db.addResult(event) + Behaviors.stopped + } + + def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + 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)) + context.log.info(s"canceled swap: $s") + case s: Fail => context.log.info(s"canceled swap: $s") + case _ => context.log.error(s"canceled swap ${failure.swapId}, reason: unknown.") + } + Behaviors.stopped + } + +} \ No newline at end of file diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala new file mode 100644 index 0000000000..414801976a --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -0,0 +1,318 @@ +/* + * 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.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.db.OutgoingPaymentStatus.Pending +import fr.acinq.eclair.db.PaymentsDbSpec.alice +import fr.acinq.eclair.db.{OutgoingPayment, OutgoingPaymentStatus, PaymentType} +import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentFailed, PaymentSent} +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.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{coopCloseCodec, 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.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, MilliSatoshiLong, NodeParams, ShortChannelId, TestConstants, TimestampMilliLong, 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._ + +// 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 = 3 + 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 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 + 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"), TestConstants.Alice.nodeParams.channelConf.minFinalExpiryDelta) + 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) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + 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]() + val nodeParams = TestConstants.Bob.nodeParams + + // 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(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, 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("send cooperative close after a restore with no pending payment") { f => + import f._ + + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false) + db.add(swapData) + swapInReceiver ! RestoreSwap(swapData) + monitor.expectMessageType[RestoreSwap] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Coop close offered to peer: Lightning payment not sent.")) + } + + test("send cooperative close after a restore with the payment already marked as failed") { 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false) + db.add(swapData) + + // add failed outgoing payment to the payments databases + val paymentId = UUID.randomUUID() + nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice.paymentHash, PaymentType.Standard, 123 msat, 123 msat, alice, 1100 unixms, Some(invoice), Pending)) + nodeParams.db.payments.updateOutgoingPayment(PaymentFailed(paymentId, invoice.paymentHash, Seq())) + assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) + + swapInReceiver ! RestoreSwap(swapData) + monitor.expectMessageType[RestoreSwap] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Coop close offered to peer: Lightning payment failed")) + } + + test("claim by invoice after a restore with the payment already marked as paid") { 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false) + db.add(swapData) + + // add paid outgoing payment to the payments databases + val paymentId = UUID.randomUUID() + nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice.paymentHash, PaymentType.Standard, 123 msat, 123 msat, alice, 1100 unixms, Some(invoice), Pending)) + nodeParams.db.payments.updateOutgoingPayment(PaymentSent(paymentId, invoice.paymentHash, paymentPreimage, invoice.amount_opt.get, makerNodeId, Seq(PaymentSent.PartialPayment(paymentId, invoice.amount_opt.get, 0 msat, channelId, None)))) + assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) + + swapInReceiver ! RestoreSwap(swapData) + monitor.expectMessageType[RestoreSwap] + + // SwapInReceiver reports status of awaiting claim-by-invoice transaction + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwap") + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTxBroadcasted.txid, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Claimed by paid invoice:")) + } + + test("claim by invoice after a restore with the payment marked as pending and later paid") { 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false) + db.add(swapData) + + // add pending outgoing payment to the payments databases + nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(UUID.randomUUID(), UUID.randomUUID(), Some("1"), invoice.paymentHash, PaymentType.Standard, 123 msat, 123 msat, alice, 1100 unixms, Some(invoice), OutgoingPaymentStatus.Pending)) + assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) + + swapInReceiver ! RestoreSwap(swapData) + monitor.expectMessageType[RestoreSwap] + + // SwapInReceiver reports status of awaiting opening transaction + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "payClaimInvoice") + + // pending payment successfully sent by SwapInReceiver + 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) + + // SwapInReceiver reports status of awaiting claim-by-invoice transaction + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwap") + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTxBroadcasted.txid, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Claimed by paid invoice:")) + } + + test("happy path for new swap in") { f => + import f._ + + // start new SwapInReceiver + swapInReceiver ! StartSwapInReceiver(request) + monitor.expectMessage(StartSwapInReceiver(request)) + + // SwapInReceiver:SwapInAgreement -> SwapInSender + val agreement = swapInAgreementCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + + // 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 + 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[SwapStatus].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.txid, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + monitor.expectMessageType[ClaimTxConfirmed] + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Claimed by paid invoice:")) + } + + test("invalid invoice, min_final-cltv-expiry of invoice greater than the claim-by-csv delta") { f => + import f._ + + // start new SwapInReceiver + swapInReceiver ! StartSwapInReceiver(request) + monitor.expectMessage(StartSwapInReceiver(request)) + + // SwapInReceiver:SwapInAgreement -> SwapInSender + val agreement = swapInAgreementCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + + // Maker:OpeningTxBroadcasted -> Taker, with payreq invoice where minFinalCltvExpiry >= claimByCsvDelta + val badInvoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapInReceiver invoice - bad min-final-cltv-expiry-delta"), claimByCsvDelta) + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, badInvoice.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 fails before paying the invoice + paymentInitiator.expectNoMessage() + + // SwapInReceiver:CoopClose -> SwapInSender + val coopClose = coopCloseCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + assert(coopClose.message.contains("min-final-cltv-expiry delta too long")) + } +} diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala new file mode 100644 index 0000000000..f323fddf3c --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala @@ -0,0 +1,185 @@ +/* + * 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.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.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.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._ + +// 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 = 3 + 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 = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.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 + 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("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 = "" + 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]() + 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]() + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + + // 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 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(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 sender") { f => + import f._ + + // start new SwapOutSender + swapOutSender ! StartSwapOutSender(amount, swapId, shortChannelId) + monitor.expectMessageType[StartSwapOutSender] + + // SwapOutSender: SwapOutRequest -> SwapOutReceiver + val request = swapOutRequestCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + assert(request.pubkey == takerPubkey.toHex) + + // SwapOutReceiver: SwapOutAgreement -> SwapOutSender (request fee) + swapOutSender ! SwapMessageReceived(SwapOutAgreement(request.protocolVersion, request.swapId, makerPubkey.toString(), feeInvoice.toString)) + monitor.expectMessageType[SwapMessageReceived] + + // 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)) + swapOutSender ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") + + // wait for SwapOutSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // 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) + + // SwapOutSender reports status of awaiting opening transaction after paying claim invoice + swapOutSender ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") + + // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) + swapOutSender ! SwapMessageReceived(openingTxBroadcasted) + monitor.expectMessageType[SwapMessageReceived] + + // ZmqWatcher -> SwapOutSender, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(request.amount.sat, makerPubkey, takerPubkey, paymentInvoice.paymentHash)), 0) + swapOutSender ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + monitor.expectMessageType[OpeningTxConfirmed] + + // SwapOutSender validates invoice and opening transaction before paying the invoice + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(paymentInvoice.amount_opt.get, paymentInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + + // wait for SwapOutSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // 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() + + // 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) + + // SwapOutSender reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + 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] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapOutSender) + + // the swap result has been recorded in the db + assert(db.list().head.result.contains("Claimed by paid invoice:")) + } +} From 21bd548462ab3a3b3126c7b587069dfb9f28e4c4 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 13:20:00 +0200 Subject: [PATCH 14/32] Add peerswap swap register and tests --- .../plugins/peerswap/StatusAggregator.scala | 44 +++ .../plugins/peerswap/SwapRegister.scala | 176 ++++++++++++ .../plugins/peerswap/SwapRegisterSpec.scala | 272 ++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.scala create mode 100644 plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala 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..c199e2098e --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/StatusAggregator.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 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 => + if (swapsCount == 0) { + replyTo ! Seq() + Behaviors.stopped + } else { + 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 new file mode 100644 index 0000000000..bdb85b1c45 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.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.plugins.peerswap + +import akka.actor +import akka.actor.typed +import akka.actor.typed.ActorRef.ActorRefOps +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.io.UnknownMessageReceived +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapRegister.Command +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, SwapRequest} +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.unknownMessageCodec +import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} +import scodec.Attempt + +import scala.reflect.ClassTag + +object SwapRegister { + // @formatter:off + sealed trait Command + sealed trait ReplyToMessages extends Command { + 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 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 + // @formatter:on + + 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, 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) + 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 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 => + val swap: typed.ActorRef[SwapCommands.SwapCommand] = { + state.swapRole match { + 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, keyManager, db)) + .onFailure(typed.SupervisorStrategy.restart), "SwapTaker-" + state.request.scid) + } + } + context.watchWith(swap, SwapTerminated(state.request.swapId)) + swap ! RestoreSwap(state) + state.request.swapId -> SwapEntry(state.request.scid, swap.unsafeUpcast) + }.toMap + registering(swaps) + } + + 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)) + .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) + context.watchWith(swap, SwapTerminated(swapId)) + swap ! StartSwapInSender(amount, swapId, shortChannelId) + replyTo ! SwapOpened(swapId) + 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)) + .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) + context.watchWith(swap, SwapTerminated(swapId)) + swap ! StartSwapOutSender(amount, swapId, shortChannelId) + replyTo ! SwapOpened(swapId) + registering(swaps + (swapId -> SwapEntry(shortChannelId.toCoordinatesString, swap))) + case ListPendingSwaps(replyTo: ActorRef[Iterable[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(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(unknownMessageCodec.encode(unknownMessageReceived.message).require) match { + case Attempt.Successful(decodedMessage) => decodedMessage.value match { + case swapRequest: SwapRequest if swaps.exists(s => s._2.shortChannelId == swapRequest.scid) => + context.log.info(s"ignoring swap request for a channel with an active swap: $swapRequest") + 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/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala new file mode 100644 index 0000000000..3892c01448 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala @@ -0,0 +1,272 @@ +/* + * 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.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, Crypto, Satoshi, SatoshiLong} +import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance +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.db.OutgoingPaymentStatus.Pending +import fr.acinq.eclair.db.{OutgoingPayment, PaymentType} +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.SwapHelpers.makeUnknownMessage +import fr.acinq.eclair.plugins.peerswap.SwapRegister._ +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 +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.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, MilliSatoshiLong, NodeParams, ShortChannelId, TestConstants, TimestampMilli, TimestampMilliLong, ToMilliSatoshiConversion, randomBytes32} +import org.scalatest.concurrent.PatienceConfiguration +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 ParallelTestExecution { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 3 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val fee: Satoshi = 22 sat + 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 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 + 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 = { + 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[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) + + 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[Any]) + + test("restore the swap register from the database") { f => + import f._ + + 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 nodeParams = TestConstants.Alice.nodeParams + + // add pending outgoing payment to the payments databases + val paymentId = UUID.randomUUID() + nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice(1).paymentHash, PaymentType.Standard, 123 msat, 123 msat, aliceNodeId, 1100 unixms, Some(invoice(0)), Pending)) + assert(nodeParams.db.payments.listOutgoingPayments(invoice(1).paymentHash).nonEmpty) + + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(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(openingTxBroadcasted(0).payreq).get.paymentHash + val paymentReceived0 = PaymentReceived(paymentHash0, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived0) + + // swapId0 - SwapRegister received notice that SwapInSender swap completed + val swap0Completed = swapEvents.expectMessageType[ClaimByInvoicePaid] + assert(swap0Completed.swapId === swapId(0)) + + // swapId0: SwapRegister receives notification that the swap Maker actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(0)) + + // 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(paymentId, 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, openingTxBroadcasted(1).txid, 0) + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx) + + // swapId1 - SwapRegister received notice that SwapOutSender completed + swapEvents.expectMessageType[TransactionPublished] + assert(swapEvents.expectMessageType[ClaimByInvoiceConfirmed].swapId === swapId(1)) + + // 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 => + 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() + + // User:SwapInRequested -> SwapInRegister + swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + val swapId = userCli.expectMessageType[SwapOpened].swapId + monitor.expectMessageType[SwapInRequested] + + // Alice:SwapInRequest -> Bob + val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.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 ! 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] + + // Alice:OpeningTxBroadcasted -> Bob + val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + + // 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].swapId === swapId) + + 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.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 swaps in the register") { f => + import f._ + + 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 nodeParams = TestConstants.Alice.nodeParams + + // add pending outgoing payment to the payments databases + val paymentId = UUID.randomUUID() + nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice(1).paymentHash, PaymentType.Standard, 123 msat, 123 msat, aliceNodeId, 1100 unixms, Some(invoice(0)), Pending)) + assert(nodeParams.db.payments.listOutgoingPayments(invoice(1).paymentHash).nonEmpty) + + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") + + val listCli = TestProbe[Iterable[Response]]() + swapRegister ! ListPendingSwaps(listCli.ref) + val responses = listCli.expectMessageType[Iterable[Response]] + assert(responses.size == 2) + } +} From c25a2e66421058a45507cddd031b66d73d2e9f5f Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 14:09:02 +0200 Subject: [PATCH 15/32] Add peerswap integration tests --- .../peerswap/SwapIntegrationFixture.scala | 65 ++++ .../peerswap/SwapIntegrationSpec.scala | 306 ++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala new file mode 100644 index 0000000000..7c5fdcaf7e --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala @@ -0,0 +1,65 @@ +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 +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.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 + +import java.sql.DriverManager + +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 = { + TestKit.shutdownActorSystem(alice.system) + TestKit.shutdownActorSystem(bob.system) + TestKit.shutdownActorSystem(system) + } +} + +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, "alice-swap-integration") + val bob = MinimalNodeFixture(bobParams, "bob-swap-integration") + 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]) + 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/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala new file mode 100644 index 0000000000..39120e5373 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -0,0 +1,306 @@ +package fr.acinq.eclair.plugins.peerswap + +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.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.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.transactions.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.plugins.peerswap.transactions.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 + +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")) + .copy(pluginParams = Seq(new PeerSwapPlugin().params)) + val bobParams = nodeParamsFor("bob", ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492")) + .copy(invoiceExpiry = 2 seconds, pluginParams = Seq(new PeerSwapPlugin().params)) + TwoNodesFixture(aliceParams, bobParams, testData.name) + } + + override def cleanupFixture(fixture: FixtureParam): Unit = { + fixture.cleanup() + } + + 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]) + bob.system.eventStream.subscribe(bobSwap.swapEvents.ref, classOf[SwapEvent]) + (aliceSwap, bobSwap) + } + + 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]) + + eventually(PatienceConfiguration.Timeout(2 seconds), PatienceConfiguration.Interval(1 second)) { + getRouterData(alice)(system).privateChannels.size == 1 + } + alice.watcher.expectMsgType[WatchExternalChannelSpent] + bob.watcher.expectMsgType[WatchExternalChannelSpent] + + shortChannelId + } + + test("swap in - claim by invoice") { f => + import f._ + + val (aliceSwap, bobSwap) = swapActors(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 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) + 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 + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + 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) + 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 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) + + // 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) = swapActors(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 + 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) + 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 + assert(openingTx.txOut.head.amount == amount + premium) + + // bob has status of 1 pending swap + 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 + aliceSwap.swapRegister ! ListPendingSwaps(aliceSwap.cli.ref.toTyped) + 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 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) + + // 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 (aliceSwap, bobSwap) = swapActors(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 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) + 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 + assert(openingTx.txOut.head.amount == amount + premium) + + // swap in receiver (alice) stops unexpectedly + aliceSwap.swapRegister.toClassic ! Kill + + // bob has status of 1 pending swap + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + 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[WatchFundingDeeplyBuried].replyTo ! WatchFundingDeeplyBuriedTriggered(claimByCsvBlock, 0, openingTx) + + // 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) + + // swap in sender (bob) confirms claim-by-csv + 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) = swapActors(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 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) + 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 + aliceSwap.swapRegister ! CancelSwapRequested(aliceSwap.cli.ref.toTyped, 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) + } + + test("swap out - claim by invoice") { f => + import f._ + + val (aliceSwap, bobSwap) = swapActors(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) + 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) + 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 + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + 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) + } + +} From 92de6298ca6f181551b34688261e978f76511f07 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 27 Oct 2022 14:18:46 +0200 Subject: [PATCH 16/32] Create peerswap plugin --- plugins/peerswap/README.md | 34 ++++ plugins/peerswap/pom.xml | 164 ++++++++++++++++++ .../eclair/plugins/peerswap/ApiHandlers.scala | 70 ++++++++ .../plugins/peerswap/ApiSerializers.scala | 57 ++++++ .../plugins/peerswap/PeerSwapPlugin.scala | 116 +++++++++++++ .../plugins/peerswap/ApiHandlersSpec.scala | 5 + .../plugins/peerswap/PeerSwapSpec.scala | 71 ++++++++ pom.xml | 1 + 8 files changed, 518 insertions(+) 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 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/PeerSwapPlugin.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlersSpec.scala create mode 100644 plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala diff --git a/plugins/peerswap/README.md b/plugins/peerswap/README.md new file mode 100644 index 0000000000..d8f04e8bdf --- /dev/null +++ b/plugins/peerswap/README.md @@ -0,0 +1,34 @@ +# Peerswap plugin + +This plugin allows implements the PeerSwap protocol: https://github.com/ElementsProject/peerswap-spec/blob/main/peer-protocol.md + +Disclaimer: PeerSwap is beta-grade software. + +We currently only recommend using PeerSwap with small balances or on signet/testnet + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## 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..ef1733fc1f --- /dev/null +++ b/plugins/peerswap/pom.xml @@ -0,0 +1,164 @@ + + + + + 4.0.0 + + fr.acinq.eclair + eclair_2.13 + 0.7.1-SNAPSHOT + ../../pom.xml + + + 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..eb5eea7afc --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala @@ -0,0 +1,70 @@ +/* + * 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.ApiSerializers.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 swapHistory: Route = postRequest("swaphistory") { implicit t => + complete(kit.swapHistory()) + } + + val peerSwapRoutes: Route = swapIn ~ swapOut ~ listSwaps ~ cancelSwap ~ swapHistory + + peerSwapRoutes + } + +} + + 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..1990607aa1 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiSerializers.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.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) + }) + + object SwapDataSerializer extends MinimalSerializer({ + case x: SwapData => JObject(List( + JField("swap_id", JString(x.request.swapId)), + JField("result", JString(x.result)), + JField("request", JString(x.request.json)), + JField("agreement", JString(x.agreement.json)), + JField("invoice", JString(x.invoice.toString)), + JField("openingTxBroadcasted", JString(x.openingTxBroadcasted.json)), + JField("swapRole", JString(x.swapRole.toString)), + JField("isInitiator", JString(x.isInitiator.toString)) + )) + }) + + implicit val formats: Formats = PeerSwapJsonSerializers.formats + SwapResponseSerializer + SwapStatusSerializer + SwapDataSerializer + +} 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..dd3d687405 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala @@ -0,0 +1,116 @@ +/* + * 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 com.google.common.util.concurrent.ThreadFactoryBuilder +import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.db.DualDatabases.runAsync +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, randomBytes32} +import grizzled.slf4j.Logging +import scodec.bits.ByteVector + +import java.io.File +import java.nio.file.Files +import java.util.concurrent.Executors +import scala.concurrent.{ExecutionContext, 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 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)) + } + + 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, db) + } + + override def route(eclairDirectives: EclairDirectives): Route = ApiHandlers.registerRoutes(pluginKit, eclairDirectives) + +} + +case class PeerSwapKit(nodeParams: NodeParams, system: ActorSystem, swapRegister: ActorRef[SwapRegister.Command], db: SwapsDb) { + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build())) + + 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) + + def swapHistory()(implicit timeout: Timeout): Future[Iterable[SwapData]] = + runAsync(db.list()) +} 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 ? 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 new file mode 100644 index 0000000000..b1f9014165 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapSpec.scala @@ -0,0 +1,71 @@ +/* + * 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.testkit.typed.scaladsl.ScalaTestWithActorTestKit +import com.typesafe.config.{Config, ConfigFactory} +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +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.AnyFunSuiteLike +import scodec.bits._ + +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong + +class PeerSwapSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with AnyFunSuiteLike { + val protocolVersion = 3 + 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)) + + 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) + } + + 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? + +} diff --git a/pom.xml b/pom.xml index 87d34421dd..330bd866a2 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 7f58a751228570b7236fcb571dfee37645b8ff19 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 8 Nov 2022 10:19:46 +0100 Subject: [PATCH 17/32] Disable premium check and allow message resends for CLN integration testing --- .../fr/acinq/eclair/plugins/peerswap/SwapTaker.scala | 4 +++- .../plugins/peerswap/SwapIntegrationSpec.scala | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) 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 6fd4257fd0..8a54122265 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 @@ -144,7 +144,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, implicit val timeout: Timeout = 30 seconds 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 premium = 0 // (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 @@ -249,6 +249,8 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { case OpeningTxConfirmed(opening) => validateOpeningTx(request, agreement, openingTxBroadcasted, opening.tx, isInitiator) + case SwapMessageReceived(resend: OpeningTxBroadcasted) if resend == openingTxBroadcasted => + Behaviors.same 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.") diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala index 39120e5373..91bbb42102 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -15,7 +15,7 @@ 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.transactions.SwapScripts.claimByCsvDelta -import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, openingTxWeight} +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.openingTxWeight import fr.acinq.eclair.testutils.FixtureSpec import fr.acinq.eclair.{BlockHeight, ShortChannelId} import org.scalatest.TestData @@ -87,7 +87,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // 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 premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByInvoiceBlock = BlockHeight(4) bob.wallet.confirmedBalance = amount + premium @@ -115,7 +115,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // 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 + // TODO: 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 @@ -132,7 +132,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // 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 premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByCoopBlock = BlockHeight(2) bob.wallet.confirmedBalance = amount + premium @@ -184,7 +184,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // 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 premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByCsvBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight bob.wallet.confirmedBalance = amount + premium @@ -226,7 +226,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // 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 premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByCoopBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight bob.wallet.confirmedBalance = amount + premium From b527e8ef3e2b27ff4de256152487541acbb66938 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 22 Nov 2022 10:40:07 +0100 Subject: [PATCH 18/32] Add test for maker with insufficient on-chain balance --- .../peerswap/SwapIntegrationSpec.scala | 84 +++++++++++++------ 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala index 91bbb42102..1e7b085aa1 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -5,7 +5,10 @@ import akka.actor.{ActorSystem, Kill} import akka.testkit.TestProbe import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi +import fr.acinq.eclair.blockchain.DummyOnChainWallet +import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.blockchain.fee.FeeratePerKw 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 @@ -20,8 +23,9 @@ 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 +import scodec.bits.{ByteVector, HexStringSyntax} +import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.duration.DurationInt /** @@ -78,19 +82,30 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { shortChannelId } + def nodeWithOnChainBalance(node: MinimalNodeFixture, balance: Satoshi): MinimalNodeFixture = node.copy(wallet = new DummyOnChainWallet() { + override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { + if (amount <= balance) { + val tx = DummyOnChainWallet.makeDummyFundingTx(pubkeyScript, amount) + funded += (tx.fundingTx.txid -> tx.fundingTx) + Future.successful(tx) + } else { + Future.failed(new RuntimeException("insufficient funds")) + } + } + }) + test("swap in - claim by invoice") { f => import f._ - val (aliceSwap, bobSwap) = swapActors(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 premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByInvoiceBlock = BlockHeight(4) - bob.wallet.confirmedBalance = amount + premium + // bob must have enough on-chain balance to send + val fundedBob = nodeWithOnChainBalance(bob, amount+premium) + val (aliceSwap, bobSwap) = swapActors(alice, fundedBob) + val shortChannelId = connectNodes(alice, fundedBob) // swap in sender (bob) requests a swap in with swap in receiver (alice) bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) @@ -126,16 +141,14 @@ 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) = swapActors(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 val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) val premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByCoopBlock = BlockHeight(2) - bob.wallet.confirmedBalance = amount + premium + val (aliceSwap, bobSwap) = swapActors(alice, bob) + val shortChannelId = connectNodes(alice, bob) // swap in sender (bob) requests a swap in with swap in receiver (alice) bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) @@ -178,16 +191,15 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { test("swap in - claim by csv, receiver does not pay after opening tx confirmed") { f => import f._ - val (aliceSwap, bobSwap) = swapActors(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 premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByCsvBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight - bob.wallet.confirmedBalance = amount + premium + // bob must have enough on-chain balance to send + val fundedBob = nodeWithOnChainBalance(bob, amount + premium) + val (aliceSwap, bobSwap) = swapActors(alice, fundedBob) + val shortChannelId = connectNodes(alice, fundedBob) // swap in sender (bob) requests a swap in with swap in receiver (alice) bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) @@ -220,16 +232,15 @@ 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) = swapActors(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 premium = 0.sat // TODO: (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat val openingBlock = BlockHeight(1) val claimByCoopBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight - bob.wallet.confirmedBalance = amount + premium + // bob must have enough on-chain balance to send + val fundedBob = nodeWithOnChainBalance(bob, amount + premium) + val (aliceSwap, bobSwap) = swapActors(alice, fundedBob) + val shortChannelId = connectNodes(alice, fundedBob) // swap in sender (bob) requests a swap in with swap in receiver (alice) bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) @@ -257,16 +268,15 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { test("swap out - claim by invoice") { f => import f._ - val (aliceSwap, bobSwap) = swapActors(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 + // bob must have enough on-chain balance to send + val fundedBob = nodeWithOnChainBalance(bob, amount + fee) + val (aliceSwap, bobSwap) = swapActors(alice, fundedBob) + val shortChannelId = connectNodes(alice, fundedBob) // swap out receiver (alice) requests a swap out with swap out sender (bob) aliceSwap.swapRegister ! SwapOutRequested(aliceSwap.cli.ref.toTyped, amount, shortChannelId) @@ -303,4 +313,28 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { assert(bobSwap.swapEvents.expectMsgType[ClaimByInvoicePaid].swapId == swapId) } + test("swap in - sender cancels because they do not have sufficient on-chain balance") { f => + import f._ + + val amount = Satoshi(1000) + // bob must have enough on-chain balance to create opening tx + val fundedBob = nodeWithOnChainBalance(bob, Satoshi(0)) + val (aliceSwap, bobSwap) = swapActors(alice, fundedBob) + val shortChannelId = connectNodes(alice, fundedBob) + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + bobSwap.cli.expectMsgType[SwapOpened] + + // both parties publish that the swap was canceled because bob could not fund the opening tx + assert(aliceSwap.swapEvents.expectMsgType[Canceled].reason.contains("error while funding swap open tx")) + assert(bobSwap.swapEvents.expectMsgType[Canceled].reason.contains("error while funding swap open tx")) + + // both parties have no pending swaps + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + assert(bobSwap.cli.expectMsgType[Iterable[Status]].isEmpty) + aliceSwap.swapRegister ! ListPendingSwaps(aliceSwap.cli.ref.toTyped) + assert(aliceSwap.cli.expectMsgType[Iterable[Status]].isEmpty) + } + } From c3fff15dde9881e44dec83b8d62102270b355074 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 23 Nov 2022 16:44:50 +0100 Subject: [PATCH 19/32] Remove unused/unsafe AbortSwap command --- .../scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala | 1 - .../main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala | 1 - .../main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala | 1 - 3 files changed, 3 deletions(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala index e41ebaadbf..db55728fd9 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala @@ -35,7 +35,6 @@ object SwapCommands { case class StartSwapInSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand case class StartSwapOutReceiver(request: SwapOutRequest) extends SwapCommand case class RestoreSwap(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 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 747d0461fb..db797e007f 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 @@ -126,7 +126,6 @@ object SwapMaker { case Failure(e) => context.log.error(s"could not restore swap sender with invalid shortChannelId: $d, $e") Behaviors.stopped } - case AbortSwap => Behaviors.stopped } } } 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 8a54122265..8cdc6f11e5 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 @@ -133,7 +133,6 @@ object SwapTaker { case Failure(e) => context.log.error(s"could not restore swap receiver with invalid shortChannelId: $d, $e") Behaviors.stopped } - case AbortSwap => Behaviors.stopped } } } From 036e7f6d1e60163af2ae1930ff0776e107be29f7 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 24 Nov 2022 09:46:21 +0100 Subject: [PATCH 20/32] Spawn swap actors with `stop` supervisor strategy Restore a stopped swap with a checkpoint that does not include a result; otherwise, remove from the swap register. --- .../eclair/plugins/peerswap/SwapData.scala | 5 +- .../eclair/plugins/peerswap/SwapEvents.scala | 3 ++ .../eclair/plugins/peerswap/SwapMaker.scala | 3 +- .../plugins/peerswap/SwapRegister.scala | 49 ++++++++++--------- .../eclair/plugins/peerswap/SwapTaker.scala | 3 +- 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala index 4baae92e55..e93f399a16 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala @@ -26,7 +26,10 @@ object SwapRole extends Enumeration { val Taker: SwapRole.Value = Value(2, "Taker") } -case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, swapRole: SwapRole, isInitiator: Boolean, result: String = "") +case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, swapRole: SwapRole, isInitiator: Boolean, result: String = "") { + val swapId: String = request.swapId + val scid: String = request.scid +} object SwapData { } diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala index eef6bca5b0..9ff2e75a72 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala @@ -42,5 +42,8 @@ object SwapEvents { case class ClaimByCsvConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { override def toString: String = s"Claimed by csv: $confirmation" } + case class CouldNotRestore(swapId: String, checkpoint: SwapData) extends SwapEvent { + override def toString: String = s"Could not restore from checkpoint: $checkpoint" + } } 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 db797e007f..228f1cf92c 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 @@ -123,7 +123,8 @@ object SwapMaker { ShortChannelId.fromCoordinates(d.request.scid) match { 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") + case Failure(e) => context.log.error(s"Could not restore from a checkpoint with an invalid shortChannelId: $d, $e") + db.addResult(CouldNotRestore(d.swapId, d)) Behaviors.stopped } } 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 bdb85b1c45..8c62c80870 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 @@ -61,7 +61,7 @@ object SwapRegister { // @formatter:on 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 + new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, data).start } } @@ -85,19 +85,19 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam context.messageAdapter[UnknownMessageReceived](WrappedUnknownMessageReceived) } - private def initializing: Behavior[Command] = { - 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, keyManager, db)) - .onFailure(typed.SupervisorStrategy.restart), "SwapMaker-" + state.request.scid) - case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) - .onFailure(typed.SupervisorStrategy.restart), "SwapTaker-" + state.request.scid) - } - } - context.watchWith(swap, SwapTerminated(state.request.swapId)) - swap ! RestoreSwap(state) - state.request.swapId -> SwapEntry(state.request.scid, swap.unsafeUpcast) + private def restoreSwap(checkPoint: SwapData): (String, SwapEntry) = { + val swap = checkPoint.swapRole match { + case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapMaker-" + checkPoint.scid) + case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapTaker-" + checkPoint.scid) + } + context.watchWith(swap, SwapTerminated(checkPoint.swapId)) + swap ! RestoreSwap(checkPoint) + checkPoint.swapId -> SwapEntry(checkPoint.scid, swap.unsafeUpcast) + } + + private def start: Behavior[Command] = { + val swaps = data.map { + restoreSwap }.toMap registering(swaps) } @@ -111,16 +111,14 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam Behaviors.same case SwapInRequested(replyTo, amount, shortChannelId) => val swapId = randomBytes32().toHex - val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) - .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)).onFailure(SupervisorStrategy.stop), "Swap-" + shortChannelId.toString) context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapInSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) 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)) - .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)).onFailure(SupervisorStrategy.stop), "Swap-" + shortChannelId.toString) context.watchWith(swap, SwapTerminated(swapId)) swap ! StartSwapOutSender(amount, swapId, shortChannelId) replyTo ! SwapOpened(swapId) @@ -136,7 +134,14 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam } Behaviors.same case SwapTerminated(swapId) => - registering(swaps - swapId) + db.restore().collectFirst({ + case checkPoint if checkPoint.swapId == swapId => + context.log.error(s"Swap $swapId stopped prematurely after saving a checkpoint, but before recording a result.") + restoreSwap(checkPoint) + }) match { + case None => registering (swaps - swapId) + case Some (restoredSwap) => registering (swaps + restoredSwap) + } case WrappedUnknownMessageReceived(unknownMessageReceived) => if (PeerSwapPlugin.peerSwapTags.contains(unknownMessageReceived.message.tag)) { peerSwapMessageCodec.decode(unknownMessageCodec.encode(unknownMessageReceived.message).require) match { @@ -145,14 +150,12 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam context.log.info(s"ignoring swap request for a channel with an active swap: $swapRequest") 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) + 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) + 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))) 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 8cdc6f11e5..e0b615d77e 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 @@ -130,7 +130,8 @@ object SwapTaker { // if payment was not yet sent, fail the swap swap.sendCoopClose(d.request, s"Lightning payment not sent.") ) - case Failure(e) => context.log.error(s"could not restore swap receiver with invalid shortChannelId: $d, $e") + case Failure(e) => context.log.error(s"Could not restore from a checkpoint with an invalid shortChannelId: $d, $e") + db.addResult(CouldNotRestore(d.swapId, d)) Behaviors.stopped } } From 42b29ecb42754f3b02ec41074fc282d6e0ba7b3f Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 25 Nov 2022 10:32:56 +0100 Subject: [PATCH 21/32] Remove dependency on unsealing TransactionWithInputInfo trait --- .../eclair/plugins/peerswap/SwapHelpers.scala | 11 ++++++++--- .../peerswap/transactions/SwapTransactions.scala | 14 ++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) 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 f5f8d758af..059841ae3f 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 @@ -21,6 +21,7 @@ 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.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, Transaction} import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi @@ -35,10 +36,9 @@ import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} 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.transactions.SwapTransactions.{SwapTransactionWithInputInfo, 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.UnknownMessage import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond, randomBytes32} @@ -126,7 +126,12 @@ object SwapHelpers { } } - def commitClaim(wallet: OnChainWallet)(swapId: String, txInfo: TransactionWithInputInfo, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = + def checkSpendable(txinfo: SwapTransactionWithInputInfo): Try[Unit] = { + // NB: we don't verify the other inputs as they should only be wallet inputs used to RBF the transaction + Try(Transaction.correctlySpends(txinfo.tx, Map(txinfo.input.outPoint -> txinfo.input.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + } + + def commitClaim(wallet: OnChainWallet)(swapId: String, txInfo: SwapTransactionWithInputInfo, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = checkSpendable(txInfo) match { case Success(_) => // publish claim tx diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala index de58f78ffd..8dbeeddb3a 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala @@ -29,10 +29,16 @@ import scodec.bits.ByteVector 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" } + sealed trait SwapTransactionWithInputInfo { + def input: InputInfo + def desc: String + def tx: Transaction + def amountIn: Satoshi = input.txOut.amount + def fee: Satoshi = amountIn - tx.txOut.map(_.amount).sum + } + case class SwapClaimByInvoiceTx(override val input: InputInfo, override val tx: Transaction) extends SwapTransactionWithInputInfo { override def desc: String = "swap-claimbyinvoice-tx" } + case class SwapClaimByCoopTx(override val input: InputInfo, override val tx: Transaction) extends SwapTransactionWithInputInfo { override def desc: String = "swap-claimbycoop-tx" } + case class SwapClaimByCsvTx(override val input: InputInfo, override val tx: Transaction) extends SwapTransactionWithInputInfo { 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 From 9d3a74e43382b3480efb2de9e4a5c68ded0f93d4 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 29 Nov 2022 16:37:38 +0100 Subject: [PATCH 22/32] Refactor to send unknown messages via the switchboard instead of the register --- .../plugins/peerswap/PeerSwapPlugin.scala | 7 +- .../eclair/plugins/peerswap/SwapData.scala | 3 +- .../eclair/plugins/peerswap/SwapEvents.scala | 2 +- .../eclair/plugins/peerswap/SwapHelpers.scala | 29 +--- .../eclair/plugins/peerswap/SwapMaker.scala | 26 ++-- .../plugins/peerswap/SwapRegister.scala | 125 ++++++++++++------ .../plugins/peerswap/SwapResponses.scala | 11 +- .../eclair/plugins/peerswap/SwapTaker.scala | 25 ++-- .../eclair/plugins/peerswap/db/SwapsDb.scala | 6 +- .../plugins/peerswap/db/pg/PgSwapsDb.scala | 10 +- .../peerswap/db/sqlite/SqliteSwapsDb.scala | 10 +- .../plugins/peerswap/SwapInReceiverSpec.scala | 40 +++--- .../plugins/peerswap/SwapInSenderSpec.scala | 44 +++--- .../peerswap/SwapIntegrationFixture.scala | 2 +- .../peerswap/SwapIntegrationSpec.scala | 17 +-- .../peerswap/SwapOutReceiverSpec.scala | 52 ++++---- .../plugins/peerswap/SwapOutSenderSpec.scala | 23 ++-- .../plugins/peerswap/SwapRegisterSpec.scala | 86 +++++++----- .../plugins/peerswap/db/SwapsDbSpec.scala | 15 ++- .../transactions/SwapTransactionsSpec.scala | 2 +- 20 files changed, 297 insertions(+), 238 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 dd3d687405..756099b3b7 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,6 +29,7 @@ import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.db.DualDatabases.runAsync import fr.acinq.eclair.db.sqlite.SqliteUtils import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status} +import fr.acinq.eclair.plugins.peerswap.SwapRole.{Maker, Taker} 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, randomBytes32} @@ -88,7 +89,7 @@ class PeerSwapPlugin extends Plugin with RouteProvider with Logging { 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") + val swapRegister = kit.system.spawn(Behaviors.supervise(SwapRegister(kit.nodeParams, kit.paymentInitiator, kit.watcher, kit.register, kit.switchboard, kit.wallet, swapKeyManager, db, data)).onFailure(SupervisorStrategy.restart), "peerswap-plugin-swap-register") pluginKit = PeerSwapKit(kit.nodeParams, kit.system, swapRegister, db) } @@ -100,10 +101,10 @@ case class PeerSwapKit(nodeParams: NodeParams, system: ActorSystem, swapRegister private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build())) def swapIn(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = - swapRegister.ask(ref => SwapRegister.SwapInRequested(ref, amount, shortChannelId))(timeout, system.scheduler.toTyped) + swapRegister.ask(ref => SwapRegister.SwapRequested(ref, Maker, amount, shortChannelId, None))(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) + swapRegister.ask(ref => SwapRegister.SwapRequested(ref, Taker, amount, shortChannelId, None))(timeout, system.scheduler.toTyped) def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] = swapRegister.ask(ref => SwapRegister.ListPendingSwaps(ref))(timeout, system.scheduler.toTyped) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala index e93f399a16..9c6e471810 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala @@ -16,6 +16,7 @@ package fr.acinq.eclair.plugins.peerswap +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.payment.Bolt11Invoice import fr.acinq.eclair.plugins.peerswap.SwapRole.SwapRole import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapAgreement, SwapRequest} @@ -26,7 +27,7 @@ object SwapRole extends Enumeration { val Taker: SwapRole.Value = Value(2, "Taker") } -case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, swapRole: SwapRole, isInitiator: Boolean, result: String = "") { +case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, swapRole: SwapRole, isInitiator: Boolean, remoteNodeId: PublicKey, result: String = "") { val swapId: String = request.swapId val scid: String = request.scid } diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala index 9ff2e75a72..5055143ff7 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala @@ -27,7 +27,7 @@ object SwapEvents { case class Canceled(swapId: String, reason: String) extends SwapEvent case class TransactionPublished(swapId: String, tx: Transaction, desc: String) extends SwapEvent - case class ClaimByInvoiceConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent{ + case class ClaimByInvoiceConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { override def toString: String = s"Claimed by paid invoice: $confirmation" } case class ClaimByCoopOffered(swapId: String, reason: String) extends SwapEvent { 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 059841ae3f..78b7ebc07a 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 @@ -30,8 +30,8 @@ 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.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} import fr.acinq.eclair.plugins.peerswap.SwapCommands._ @@ -40,7 +40,7 @@ import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{SwapTrans import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted} import fr.acinq.eclair.wire.protocol.UnknownMessage -import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond, randomBytes32} +import fr.acinq.eclair.{NodeParams, TimestampSecond, randomBytes32} import scala.concurrent.ExecutionContext.Implicits.global import scala.reflect.ClassTag @@ -48,15 +48,6 @@ import scala.util.{Failure, Success, Try} object SwapHelpers { - 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.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}") Behaviors.receiveMessage { @@ -77,9 +68,6 @@ object SwapHelpers { 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()) - 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) @@ -94,17 +82,8 @@ object SwapHelpers { UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.drop(16).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 = - register ! Register.Forward(forwardAdapter(context), channelId, makeUnknownMessage(message)) - - def forwardAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[UnknownMessage]] = - context.messageAdapter[Register.ForwardFailure[UnknownMessage]](ForwardFailureAdapter) + def send(switchboard: actor.ActorRef, remoteNodeId: PublicKey)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + switchboard ! ForwardUnknownMessage(remoteNodeId, makeUnknownMessage(message)) 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/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 228f1cf92c..d08deda246 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 @@ -34,7 +34,7 @@ import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} 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.SwapResponses.{CreateInvoiceFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.db.SwapsDb @@ -106,22 +106,22 @@ object SwapMaker { */ - def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommands.SwapCommand] = + def apply(remoteNodeId: PublicKey, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], switchboard: 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, keyManager, db, context) + new SwapMaker(remoteNodeId, shortChannelId, nodeParams, watcher, switchboard, 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, keyManager, db, context) + case Success(shortChannelId) => new SwapMaker(remoteNodeId, shortChannelId, nodeParams, watcher, switchboard, 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, keyManager, db, context) + case Success(shortChannelId) => new SwapMaker(remoteNodeId, shortChannelId, nodeParams, watcher, switchboard, 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 from a checkpoint with an invalid shortChannelId: $d, $e") db.addResult(CouldNotRestore(d.swapId, d)) @@ -131,7 +131,7 @@ object SwapMaker { } } -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]) { +private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 3 val noAsset = "" implicit val timeout: Timeout = 30 seconds @@ -154,21 +154,21 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, awaitAgreement(SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId.toString, amount.toLong, makerPubkey(swapId).toHex)) } - def validateRequest(request: SwapOutRequest): Behavior[SwapCommand] = { + private 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")) + case Failure(exception) => swapCanceled(CreateInvoiceFailed(request.swapId, exception)) } } } 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) + send(switchboard, remoteNodeId)(agreement) Behaviors.withTimers { timers => timers.startSingleTimer(swapFeeExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) @@ -190,7 +190,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, } private def awaitAgreement(request: SwapInRequest): Behavior[SwapCommand] = { - sendShortId(register, shortChannelId)(request) + send(switchboard, remoteNodeId)(request) receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { case SwapMessageReceived(agreement: SwapInAgreement) if agreement.protocolVersion != protocolVersion => @@ -221,7 +221,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") Behaviors.same case OpeningTxCommitted(invoice, openingTxBroadcasted) => - db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator)) + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator, remoteNodeId)) 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) @@ -242,7 +242,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, 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 - sendShortId(register, shortChannelId)(openingTxBroadcasted) // send message to peer about opening tx broadcast + send(switchboard, remoteNodeId)(openingTxBroadcasted) // send message to peer about opening tx broadcast Behaviors.withTimers { timers => timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) @@ -343,7 +343,7 @@ private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, def swapCanceled(failure: Fail): Behavior[SwapCommand] = { val swapEvent = Canceled(failure.swapId, failure.toString) context.system.eventStream ! Publish(swapEvent) - if (!failure.isInstanceOf[PeerCanceled]) sendShortId(register, shortChannelId)(CancelSwap(failure.swapId, failure.toString)) + if (!failure.isInstanceOf[PeerCanceled]) send(switchboard, remoteNodeId)(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") 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 8c62c80870..1a46e57c27 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 @@ -21,21 +21,28 @@ import akka.actor.typed import akka.actor.typed.ActorRef.ActorRefOps import akka.actor.typed.scaladsl.adapter.TypedActorRefOps import akka.actor.typed.scaladsl.{ActorContext, Behaviors} -import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy} +import akka.actor.typed.{ActorRef, Behavior} +import akka.util.Timeout +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.Satoshi import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_INFO, RES_GET_CHANNEL_INFO, Register} +import fr.acinq.eclair.io.Peer.RelayUnknownMessage import fr.acinq.eclair.io.UnknownMessageReceived import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapHelpers.makeUnknownMessage import fr.acinq.eclair.plugins.peerswap.SwapRegister.Command import fr.acinq.eclair.plugins.peerswap.SwapResponses._ +import fr.acinq.eclair.plugins.peerswap.SwapRole.SwapRole 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, SwapRequest} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{CancelSwap, HasSwapId, SwapInRequest, SwapOutRequest, SwapRequest} import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.unknownMessageCodec import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} import scodec.Attempt +import scala.concurrent.duration.DurationInt import scala.reflect.ClassTag object SwapRegister { @@ -45,27 +52,21 @@ 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 ChannelInfoFailure(replyTo: ActorRef[Response], failure: Register.ForwardShortIdFailure[CMD_GET_CHANNEL_INFO]) 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 SwapRequested(replyTo: ActorRef[Response], role: SwapRole, amount: Satoshi, shortChannelId: ShortChannelId, remoteNodeId: Option[PublicKey]) 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 // @formatter:on - 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).start + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, data: Set[SwapData]): Behavior[Command] = Behaviors.setup { context => + new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, switchboard, wallet, keyManager, db, data).start } } -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]) { +private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, data: Set[SwapData]) { import SwapRegister._ case class SwapEntry(shortChannelId: String, swap: ActorRef[SwapCommands.SwapCommand]) @@ -85,16 +86,63 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam context.messageAdapter[UnknownMessageReceived](WrappedUnknownMessageReceived) } - private def restoreSwap(checkPoint: SwapData): (String, SwapEntry) = { - val swap = checkPoint.swapRole match { - case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapMaker-" + checkPoint.scid) - case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapTaker-" + checkPoint.scid) + private def spawnSwap(swapRole: SwapRole, remoteNodeId: PublicKey, scid: String) = { + swapRole match { + case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(remoteNodeId, nodeParams, watcher, switchboard, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapMaker-" + scid) + case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(remoteNodeId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapTaker-" + scid) } + } + + private def restoreSwap(checkPoint: SwapData): (String, SwapEntry) = { + val swap = spawnSwap(checkPoint.swapRole, checkPoint.remoteNodeId, checkPoint.scid) context.watchWith(swap, SwapTerminated(checkPoint.swapId)) swap ! RestoreSwap(checkPoint) checkPoint.swapId -> SwapEntry(checkPoint.scid, swap.unsafeUpcast) } + private def channelInfoResultAdapter(context: ActorContext[Command], replyTo: ActorRef[Response], role: SwapRole, amount: Satoshi, shortChannelId: ShortChannelId): ActorRef[RES_GET_CHANNEL_INFO] = + context.messageAdapter[RES_GET_CHANNEL_INFO](r => + SwapRequested(replyTo, role, amount, shortChannelId, Some(r.nodeId)) + ) + + private def channelInfoFailureAdapter(context: ActorContext[Command], replyTo: ActorRef[Response]): ActorRef[Register.ForwardShortIdFailure[CMD_GET_CHANNEL_INFO]] = + context.messageAdapter[Register.ForwardShortIdFailure[CMD_GET_CHANNEL_INFO]]( f => ChannelInfoFailure(replyTo, f)) + + private def fillRemoteNodeId(replyTo: ActorRef[Response], role: SwapRole, amount: Satoshi, shortChannelId: ShortChannelId)(implicit timeout: Timeout): Unit = { + register ! Register.ForwardShortId(channelInfoFailureAdapter(context, replyTo), shortChannelId, CMD_GET_CHANNEL_INFO(channelInfoResultAdapter(context, replyTo, role, amount, shortChannelId).toClassic)) + } + + private def initiateSwap(replyTo: ActorRef[Response], amount: Satoshi, remoteNodeId: PublicKey, shortChannelId: ShortChannelId, swapRole: SwapRole): (String, SwapEntry) = { + // TODO: check that new random swapId does not already exist in db? + val swapId = randomBytes32().toHex + val swap = spawnSwap(swapRole, remoteNodeId, shortChannelId.toCoordinatesString) + context.watchWith(swap, SwapTerminated(swapId)) + swapRole match { + case SwapRole.Taker => swap ! StartSwapOutSender(amount, swapId, shortChannelId) + case SwapRole.Maker => swap ! StartSwapInSender(amount, swapId, shortChannelId) + } + replyTo ! SwapOpened(swapId) + swapId -> SwapEntry(shortChannelId.toCoordinatesString, swap.unsafeUpcast) + } + + private def receiveSwap(remoteNodeId: PublicKey, request: SwapRequest): (String, SwapEntry) = { + val swap = spawnSwap(request match { + case _: SwapInRequest => SwapRole.Taker + case _: SwapOutRequest => SwapRole.Maker + }, remoteNodeId, request.scid) + + context.watchWith(swap, SwapTerminated(request.swapId)) + request match { + case r: SwapInRequest => swap ! StartSwapInReceiver(r) + case r: SwapOutRequest => swap ! StartSwapOutReceiver(r) + } + request.swapId -> SwapEntry(request.scid, swap.unsafeUpcast) + } + + private def cancelSwap(peer: actor.ActorRef, swapId: String, reason: String): Unit = { + peer ! RelayUnknownMessage(makeUnknownMessage(CancelSwap(swapId, reason))) + } + private def start: Behavior[Command] = { val swaps = data.map { restoreSwap @@ -103,26 +151,16 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam } private def registering(swaps: Map[String, SwapEntry]): Behavior[Command] = { + implicit val timeout: Timeout = Timeout(10 seconds) 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) + swapRequested.replyTo ! SwapExistsForChannel(swapRequested.shortChannelId.toCoordinatesString) + Behaviors.same + case SwapRequested(replyTo, role, amount, shortChannelId, None) => fillRemoteNodeId(replyTo, role, amount, shortChannelId) Behaviors.same - case SwapInRequested(replyTo, amount, shortChannelId) => - val swapId = randomBytes32().toHex - val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)).onFailure(SupervisorStrategy.stop), "Swap-" + shortChannelId.toString) - context.watchWith(swap, SwapTerminated(swapId)) - swap ! StartSwapInSender(amount, swapId, shortChannelId) - replyTo ! SwapOpened(swapId) - 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)).onFailure(SupervisorStrategy.stop), "Swap-" + shortChannelId.toString) - context.watchWith(swap, SwapTerminated(swapId)) - swap ! StartSwapOutSender(amount, swapId, shortChannelId) - replyTo ! SwapOpened(swapId) - registering(swaps + (swapId -> SwapEntry(shortChannelId.toCoordinatesString, swap))) + case SwapRequested(replyTo, role, amount, shortChannelId, Some(remoteNodeId)) => + registering(swaps + initiateSwap(replyTo, amount, remoteNodeId, shortChannelId, role)) case ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) => val aggregator = context.spawn(StatusAggregator(swaps.size, replyTo), s"status-aggregator") swaps.values.foreach(e => e.swap ! GetStatus(aggregator)) @@ -148,19 +186,15 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam case Attempt.Successful(decodedMessage) => decodedMessage.value match { case swapRequest: SwapRequest if swaps.exists(s => s._2.shortChannelId == swapRequest.scid) => context.log.info(s"ignoring swap request for a channel with an active swap: $swapRequest") + cancelSwap(unknownMessageReceived.peer, swapRequest.swapId, "Swap already in progress.") 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 swapRequest: SwapRequest if db.list().exists(s => s.swapId == swapRequest.swapId) => + context.log.error(s"ignoring swap request with a previously used swap id: $swapRequest") + cancelSwap(unknownMessageReceived.peer, swapRequest.swapId, "Previously used swap id.") + Behaviors.same + case swapRequest: SwapRequest => + registering(swaps + receiveSwap(unknownMessageReceived.nodeId, swapRequest)) 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") @@ -174,6 +208,9 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam // unknown message received without a peerswap message tag Behaviors.same } + case ChannelInfoFailure(replyTo, failure) => replyTo ! CreateFailed("", failure.toString) + 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 651a1fab8f..e7d2ac3005 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,8 +35,9 @@ 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 SwapExistsForChannel(shortChannelId: String) extends Fail { + override def swapId: String = "" + override def toString: String = s"swap already exists for channel $shortChannelId" } case class SwapNotFound(swapId: String) extends Fail { @@ -52,7 +53,11 @@ object SwapResponses { } case class CreateFailed(swapId: String, reason: String) extends Fail { - override def toString: String = s"could not create swap $swapId: $reason." + override def toString: String = s"could not create swap: $reason." + } + + case class CreateInvoiceFailed(swapId: String, throwable: Throwable) extends Fail { + override def toString: String = s"swap $swapId canceled, could not create invoice: $throwable" } case class InvalidMessage(swapId: String, behavior: String, message: HasSwapId) extends Fail { 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 e0b615d77e..1fc22a60ad 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 @@ -34,11 +34,11 @@ 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.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ import fr.acinq.eclair.plugins.peerswap.wire.protocol._ -import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, ShortChannelId, TimestampSecond, ToMilliSatoshiConversion} +import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, ShortChannelId, ToMilliSatoshiConversion} import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt @@ -104,15 +104,15 @@ object SwapTaker { */ - def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommand] = + def apply(remoteNodeId: PublicKey, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], switchboard: 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, keyManager, db, context) + new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, 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, keyManager, db, context) + case Success(shortChannelId) => new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) .validateRequest(request) case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") Behaviors.stopped @@ -120,7 +120,7 @@ object SwapTaker { case RestoreSwap(d) => ShortChannelId.fromCoordinates(d.request.scid) match { case Success(shortChannelId) => - val swap = new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) + val swap = new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) // handle a payment that has already succeeded, failed or is still pending nodeParams.db.payments.listOutgoingPayments(d.invoice.paymentHash).collectFirst { case p if p.status.isInstanceOf[Succeeded] => swap.claimSwap(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, p.status.asInstanceOf[Succeeded].paymentPreimage, d.isInitiator) @@ -138,7 +138,7 @@ object SwapTaker { } } -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]) { +private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { val protocolVersion = 3 val noAsset = "" implicit val timeout: Timeout = 30 seconds @@ -163,8 +163,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) + send(switchboard, remoteNodeId)(request) receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { case SwapMessageReceived(agreement: SwapOutAgreement) if agreement.protocolVersion != protocolVersion => @@ -228,7 +227,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, 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) + send(switchboard, remoteNodeId)(agreement) receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = false) @@ -274,7 +273,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, sendCoopClose(request, s"Invoice min-final-cltv-expiry delta too long.") case Success(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => // save restore point before a payment is initiated - db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator)) + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator, remoteNodeId)) payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, isInitiator) case Success(_) => @@ -324,7 +323,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, def sendCoopClose(request: SwapRequest, reason: String): 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)) + send(switchboard, remoteNodeId)(CoopClose(request.swapId, reason, takerPrivkey(request.swapId).toHex)) swapCompleted(ClaimByCoopOffered(request.swapId, reason)) } @@ -340,7 +339,7 @@ private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, 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)) + case s: CreateFailed => send(switchboard, remoteNodeId)(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 ${failure.swapId}, reason: unknown.") diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala index badf272dbe..f93cacd23b 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala @@ -16,6 +16,7 @@ package fr.acinq.eclair.plugins.peerswap.db +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.payment.Bolt11Invoice import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker @@ -23,6 +24,7 @@ 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 +import scodec.bits.ByteVector import java.sql.{PreparedStatement, ResultSet} @@ -51,7 +53,8 @@ object SwapsDb { statement.setString(5, Serialization.write(swapData.openingTxBroadcasted)) statement.setInt(6, swapData.swapRole.id) statement.setBoolean(7, swapData.isInitiator) - statement.setString(8, "") + statement.setString(8, swapData.remoteNodeId.toHex) + statement.setString(9, "") } def getSwapData(rs: ResultSet): SwapData = { @@ -78,6 +81,7 @@ object SwapsDb { Serialization.read[OpeningTxBroadcasted](compact(render(parse(openingTxBroadcasted_json).camelizeKeys))), SwapRole(rs.getInt("swap_role")), rs.getBoolean("is_initiator"), + PublicKey(ByteVector.fromValidHex(rs.getString("remote_node_id"))), result) } } \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala index 8b265b5a8a..3b7ef9228a 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala @@ -42,7 +42,7 @@ class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { 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)") + 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, remote_node_id TEXT 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") } @@ -53,8 +53,8 @@ class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { 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 => + """INSERT INTO swaps (swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result) + VALUES (?, ?, ?::JSON, ?::JSON, ?, ?::JSON, ?, ?, ?) ON CONFLICT (swap_id) DO NOTHING""")) { statement => setSwapData(statement, swapData) statement.executeUpdate() } @@ -82,7 +82,7 @@ class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { 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 => + using(pg.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result FROM swaps WHERE result=?")) { statement => statement.setString(1, "") statement.executeQuery().map(rs => getSwapData(rs)).toSeq } @@ -91,7 +91,7 @@ class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { 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 => + using(pg.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result FROM swaps")) { statement => statement.executeQuery().map(rs => getSwapData(rs)).toSeq } } diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala index 5bf480d0ac..1088db157c 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala @@ -40,7 +40,7 @@ class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { 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)") + 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, remote_node_id STRING 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") } @@ -49,8 +49,8 @@ class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { 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 => + """INSERT INTO swaps (swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (swap_id) DO NOTHING""")) { statement => setSwapData(statement, swapData) statement.executeUpdate() } @@ -72,14 +72,14 @@ class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { } 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 => + using(sqlite.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, 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 => + using(sqlite.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result FROM swaps")) { statement => statement.executeQuery().map(rs => getSwapData(rs)).toSeq } } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala index 414801976a..b7e4efba30 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -30,10 +30,10 @@ 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.db.OutgoingPaymentStatus.Pending import fr.acinq.eclair.db.PaymentsDbSpec.alice import fr.acinq.eclair.db.{OutgoingPayment, OutgoingPaymentStatus, PaymentType} +import fr.acinq.eclair.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentFailed, PaymentSent} import fr.acinq.eclair.plugins.peerswap.SwapCommands._ @@ -42,11 +42,11 @@ 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.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} -import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{coopCloseCodec, swapInAgreementCodec} -import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec +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.UnknownMessage -import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, MilliSatoshiLong, NodeParams, ShortChannelId, TestConstants, TimestampMilliLong, ToMilliSatoshiConversion, randomBytes32} +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs +import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, NodeParams, ShortChannelId, TestConstants, TimestampMilliLong, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{BeforeAndAfterAll, Outcome} @@ -81,12 +81,16 @@ 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 + + def expectSwapMessage[B](switchboard: TestProbe[Any]): B = { + val unknownMessage = switchboard.expectMessageType[ForwardUnknownMessage].msg + val encoded = LightningMessageCodecs.unknownMessageCodec.encode(unknownMessage).require.toByteVector + peerSwapMessageCodec.decode(encoded.toBitVector).require.value.asInstanceOf[B] + } 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]() @@ -94,27 +98,27 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. 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]() val nodeParams = TestConstants.Bob.nodeParams + val remoteNodeId = TestConstants.Bob.nodeParams.nodeId // 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(nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-in-receiver") + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(remoteNodeId, nodeParams, paymentInitiator.ref.toClassic, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db)), "swap-in-receiver") - withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, nodeParams, watcher, wallet, swapEvents))) + withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, switchboard, relayer, router, paymentInitiator, paymentHandler, nodeParams, watcher, wallet, swapEvents, remoteNodeId))) } - 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(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("send cooperative close after a restore with no pending payment") { f => import f._ val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) - val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) swapInReceiver ! RestoreSwap(swapData) monitor.expectMessageType[RestoreSwap] @@ -132,7 +136,7 @@ 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, swapRole = SwapRole.Taker, isInitiator = false) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) // add failed outgoing payment to the payments databases @@ -157,7 +161,7 @@ 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, swapRole = SwapRole.Taker, isInitiator = false) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) // add paid outgoing payment to the payments databases @@ -193,7 +197,7 @@ 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, swapRole = SwapRole.Taker, isInitiator = false) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) // add pending outgoing payment to the payments databases @@ -239,7 +243,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. monitor.expectMessage(StartSwapInReceiver(request)) // SwapInReceiver:SwapInAgreement -> SwapInSender - val agreement = swapInAgreementCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val agreement = expectSwapMessage[SwapInAgreement](switchboard) // Maker:OpeningTxBroadcasted -> Taker val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) @@ -295,7 +299,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. monitor.expectMessage(StartSwapInReceiver(request)) // SwapInReceiver:SwapInAgreement -> SwapInSender - val agreement = swapInAgreementCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val agreement = expectSwapMessage[SwapInAgreement](switchboard) // Maker:OpeningTxBroadcasted -> Taker, with payreq invoice where minFinalCltvExpiry >= claimByCsvDelta val badInvoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapInReceiver invoice - bad min-final-cltv-expiry-delta"), claimByCsvDelta) @@ -312,7 +316,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. paymentInitiator.expectNoMessage() // SwapInReceiver:CoopClose -> SwapInSender - val coopClose = coopCloseCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val coopClose = expectSwapMessage[CoopClose](switchboard) assert(coopClose.message.contains("min-final-cltv-expiry delta too long")) } } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala index d3af3c74fc..2874d4ad98 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -30,16 +30,16 @@ 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.DATA_NORMAL -import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} 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.PeerSwapMessageCodecs.peerSwapMessageCodec 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.UnknownMessage +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -73,33 +73,34 @@ 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 + + def expectSwapMessage[B](switchboard: TestProbe[Any]): B = { + val unknownMessage = switchboard.expectMessageType[ForwardUnknownMessage].msg + val encoded = LightningMessageCodecs.unknownMessageCodec.encode(unknownMessage).require.toByteVector + peerSwapMessageCodec.decode(encoded.toBitVector).require.value.asInstanceOf[B] + } 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]() + val remoteNodeId = TestConstants.Bob.nodeParams.nodeId // 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, keyManager, db)), "swap-in-sender") + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.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))) + withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, switchboard, paymentInitiator, watcher, wallet, swapEvents, remoteNodeId))) } - 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]) + case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], paymentInitiator: TestProbe[Any], watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("happy path from restored swap") { f => import f._ @@ -107,12 +108,12 @@ 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, swapRole = SwapRole.Maker, isInitiator = true) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true, remoteNodeId) db.add(swapData) swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored - register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] + expectSwapMessage[OpeningTxBroadcasted](switchboard) // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -145,16 +146,16 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo swapInSender ! StartSwapInSender(amount, swapId, shortChannelId) // SwapInSender: SwapInRequest -> SwapInSender - val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val swapInRequest = expectSwapMessage[SwapInRequest](switchboard) // SwapInReceiver: SwapInAgreement -> SwapInSender swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, takerPubkey.toString(), premium)) // SwapInSender publishes opening tx on-chain - val openingTx = swapEvents.expectMessageType[TransactionPublished].tx + swapEvents.expectMessageType[TransactionPublished].tx // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver - val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val openingTxBroadcasted = expectSwapMessage[OpeningTxBroadcasted](switchboard) val invoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get // wait for SwapInSender to subscribe to PaymentEventReceived messages @@ -165,7 +166,6 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo assert(userCli.expectMessageType[SwapStatus].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)))) testKit.system.eventStream ! Publish(paymentReceived) @@ -185,12 +185,12 @@ 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, swapRole = SwapRole.Maker, isInitiator = true) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true, remoteNodeId) db.add(swapData) swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored - openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + expectSwapMessage[OpeningTxBroadcasted](switchboard) // wait for SwapInSender to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -226,12 +226,12 @@ 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, swapRole = SwapRole.Maker, isInitiator = true) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true, remoteNodeId) db.add(swapData) swapInSender ! RestoreSwap(swapData) // resend OpeningTxBroadcasted when swap restored - openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + expectSwapMessage[OpeningTxBroadcasted](switchboard) // wait to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala index 7c5fdcaf7e..ddb34fcde3 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala @@ -34,7 +34,7 @@ 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}") + node.system.spawn(Behaviors.supervise(SwapRegister(node.nodeParams, node.paymentInitiator, node.watcher.ref.toTyped, node.register, node.switchboard, 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") diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala index 1e7b085aa1..1d16df7a05 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -6,7 +6,7 @@ import akka.testkit.TestProbe import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi import fr.acinq.eclair.blockchain.DummyOnChainWallet -import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance} +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} @@ -15,8 +15,9 @@ import fr.acinq.eclair.integration.basic.fixtures.composite.TwoNodesFixture import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentSent} 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.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapRequested} import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapRole.{Maker, Taker} import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.openingTxWeight import fr.acinq.eclair.testutils.FixtureSpec @@ -108,7 +109,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { val shortChannelId = connectNodes(alice, fundedBob) // swap in sender (bob) requests a swap in with swap in receiver (alice) - bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + bobSwap.swapRegister ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -151,7 +152,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { val shortChannelId = connectNodes(alice, bob) // swap in sender (bob) requests a swap in with swap in receiver (alice) - bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + bobSwap.swapRegister ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -202,7 +203,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { val shortChannelId = connectNodes(alice, fundedBob) // swap in sender (bob) requests a swap in with swap in receiver (alice) - bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + bobSwap.swapRegister ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx published @@ -243,7 +244,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { val shortChannelId = connectNodes(alice, fundedBob) // swap in sender (bob) requests a swap in with swap in receiver (alice) - bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + bobSwap.swapRegister ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId // swap in sender (bob) confirms opening tx is published, but NOT yet confirmed on-chain @@ -279,7 +280,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { val shortChannelId = connectNodes(alice, fundedBob) // swap out receiver (alice) requests a swap out with swap out sender (bob) - aliceSwap.swapRegister ! SwapOutRequested(aliceSwap.cli.ref.toTyped, amount, shortChannelId) + aliceSwap.swapRegister ! SwapRequested(aliceSwap.cli.ref.toTyped, Taker, amount, shortChannelId, None) val swapId = aliceSwap.cli.expectMsgType[SwapOpened].swapId // swap out receiver (alice) sends a payment of `fee` to swap out sender (bob) @@ -323,7 +324,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { val shortChannelId = connectNodes(alice, fundedBob) // swap in sender (bob) requests a swap in with swap in receiver (alice) - bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + bobSwap.swapRegister ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) bobSwap.cli.expectMsgType[SwapOpened] // both parties publish that the swap was canceled because bob could not fund the opening tx diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala index 034713ac67..ae29f6cdf0 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -30,16 +30,17 @@ 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.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} 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.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, peerSwapMessageCodec, swapOutAgreementCodec} +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.UnknownMessage +import fr.acinq.eclair.wire.protocol.{LightningMessageCodecs, UnknownMessage} import fr.acinq.eclair.{NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -74,12 +75,17 @@ 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 + val remoteNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId + + def expectSwapMessage[B](switchboard: TestProbe[Any]): B = { + val unknownMessage = switchboard.expectMessageType[ForwardUnknownMessage].msg + val encoded = LightningMessageCodecs.unknownMessageCodec.encode(unknownMessage).require.toByteVector + peerSwapMessageCodec.decode(encoded.toBitVector).require.value.asInstanceOf[B] + } 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]() @@ -87,63 +93,63 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory 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]() + val remoteNodeId = TestConstants.Bob.nodeParams.nodeId val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) // 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, keyManager, db)), "swap-out-receiver") + val swapOutReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.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))) + withFixture(test.toNoArgTest(FixtureParam(swapOutReceiver, userCli, monitor, switchboard, relayer, router, paymentInitiator, paymentHandler, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents, remoteNodeId))) } - 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]) + case class FixtureParam(swapOutReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("happy path for new swap out receiver") { f => import f._ - // start new SwapInSender - swapInSender ! StartSwapOutReceiver(request) + // start new SwapOutReceiver + swapOutReceiver ! StartSwapOutReceiver(request) monitor.expectMessage(StartSwapOutReceiver(request)) - // SwapInSender:SwapOutAgreement -> SwapInReceiver - val agreement = swapOutAgreementCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + // SwapOutReceiver:SwapOutAgreement -> SwapOutSender + val agreement = expectSwapMessage[SwapOutAgreement](switchboard) assert(agreement.pubkey == makerPubkey.toHex) - // SwapInReceiver pays the fee invoice + // SwapOutSender 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 + // SwapOutReceiver publishes opening tx on-chain val openingTx = swapEvents.expectMessageType[TransactionPublished].tx assert(openingTx.txOut.head.amount == amount) - // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver - val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender + val openingTxBroadcasted = expectSwapMessage[OpeningTxBroadcasted](switchboard) val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get - // wait for SwapInSender to subscribe to PaymentEventReceived messages + // wait for SwapOutReceiver to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() - // SwapInSender reports status of awaiting payment - swapInSender ! GetStatus(userCli.ref) + // SwapOutReceiver reports status of awaiting payment + swapOutReceiver ! GetStatus(userCli.ref) assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") - // SwapInSender receives a payment with the corresponding payment hash + // SwapOutReceiver 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 + // SwapOutReceiver reports a successful claim-by-invoice was paid for swapEvents.expectMessageType[ClaimByInvoicePaid] // wait for swap actor to stop - testKit.stop(swapInSender) + testKit.stop(swapOutReceiver) // the swap result has been recorded in the db assert(db.list().head.result.contains("Invoice payment received:")) diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala index f323fddf3c..01a37a6524 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala @@ -30,7 +30,7 @@ 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.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} import fr.acinq.eclair.plugins.peerswap.SwapCommands._ @@ -38,10 +38,10 @@ import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, Swa 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.PeerSwapMessageCodecs.peerSwapMessageCodec 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.UnknownMessage +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -80,12 +80,15 @@ 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 + def expectSwapMessage[B](switchboard: TestProbe[Any]): B = { + val unknownMessage = switchboard.expectMessageType[ForwardUnknownMessage].msg + val encoded = LightningMessageCodecs.unknownMessageCodec.encode(unknownMessage).require.toByteVector + peerSwapMessageCodec.decode(encoded.toBitVector).require.value.asInstanceOf[B] + } 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]() @@ -93,20 +96,20 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l 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]() val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + val remoteNodeId: PublicKey = TestConstants.Bob.nodeParams.nodeId // 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 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") + val swapOutSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(remoteNodeId, TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db)), "swap-out-sender") - withFixture(test.toNoArgTest(FixtureParam(swapOutSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + withFixture(test.toNoArgTest(FixtureParam(swapOutSender, userCli, monitor, switchboard, relayer, router, paymentInitiator, paymentHandler, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents, remoteNodeId))) } - 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]) + case class FixtureParam(swapOutSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("happy path for new swap out sender") { f => import f._ @@ -116,7 +119,7 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l monitor.expectMessageType[StartSwapOutSender] // SwapOutSender: SwapOutRequest -> SwapOutReceiver - val request = swapOutRequestCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val request = expectSwapMessage[SwapOutRequest](switchboard) assert(request.pubkey == takerPubkey.toHex) // SwapOutReceiver: SwapOutAgreement -> SwapOutSender (request fee) 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 3892c01448..896d38884e 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 @@ -28,22 +28,25 @@ import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance 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.channel.{CMD_GET_CHANNEL_INFO, DATA_NORMAL, NORMAL, RES_GET_CHANNEL_INFO} import fr.acinq.eclair.db.OutgoingPaymentStatus.Pending import fr.acinq.eclair.db.{OutgoingPayment, PaymentType} +import fr.acinq.eclair.io.Peer.RelayUnknownMessage +import fr.acinq.eclair.io.Switchboard.ForwardUnknownMessage 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.SwapHelpers.makeUnknownMessage import fr.acinq.eclair.plugins.peerswap.SwapRegister._ import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, SwapExistsForChannel, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapRole.{Maker, Taker} import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.makeSwapClaimByInvoiceTx -import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapInRequestCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec 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.wire.protocol.LightningMessageCodecs import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, MilliSatoshiLong, NodeParams, ShortChannelId, TestConstants, TimestampMilli, TimestampMilliLong, ToMilliSatoshiConversion, randomBytes32} import org.scalatest.concurrent.PatienceConfiguration import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -83,6 +86,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app 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 remoteNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId def paymentPreimage(index: Int): ByteVector32 = index match { case 0 => ByteVector32.Zeroes @@ -100,33 +104,46 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app def bobPubkey(swapId: String): PublicKey = bobPrivkey(swapId).publicKey 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 + def makePluginMessage(peer: TestProbe[Any], message: HasSwapId): WrappedUnknownMessageReceived = WrappedUnknownMessageReceived(UnknownMessageReceived(peer.ref.toClassic, alicePubkey(""), makeUnknownMessage(message), null)) + + def expectSwapMessage[B](switchboard: TestProbe[Any]): B = { + val unknownMessage = switchboard.expectMessageType[ForwardUnknownMessage].msg + val encoded = LightningMessageCodecs.unknownMessageCodec.encode(unknownMessage).require.toByteVector + peerSwapMessageCodec.decode(encoded.toBitVector).require.value.asInstanceOf[B] + } + + def expectCancelSwap(peer: TestProbe[Any]): CancelSwap = { + val unknownMessage = peer.expectMessageType[RelayUnknownMessage].unknownMessage + val encoded = LightningMessageCodecs.unknownMessageCodec.encode(unknownMessage).require.toByteVector + peerSwapMessageCodec.decode(encoded.toBitVector).require.value.asInstanceOf[CancelSwap] + } override def withFixture(test: OneArgTest): Outcome = { val userCli = testKit.createTestProbe[Response]() val swapEvents = testKit.createTestProbe[SwapEvent]() val register = testKit.createTestProbe[Any]() + val switchboard = 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 peer = testKit.createTestProbe[Any]() 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) - withFixture(test.toNoArgTest(FixtureParam(userCli, swapEvents, register, monitor, paymentHandler, wallet, watcher))) + withFixture(test.toNoArgTest(FixtureParam(userCli, swapEvents, register, monitor, paymentHandler, wallet, watcher, switchboard, peer))) } - case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], monitor: TestProbe[SwapRegister.Command], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[Any]) + case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], monitor: TestProbe[SwapRegister.Command], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[Any], switchboard: TestProbe[Any], peer: TestProbe[Any]) test("restore the swap register from the database") { f => import f._ - 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 savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice(0), openingTxBroadcasted(0), swapRole = SwapRole.Maker, isInitiator = true, remoteNodeId), + SwapData(swapOutRequest, swapOutAgreement, invoice(1), openingTxBroadcasted(1), swapRole = SwapRole.Taker, isInitiator = true, remoteNodeId)) val nodeParams = TestConstants.Alice.nodeParams // add pending outgoing payment to the payments databases @@ -134,7 +151,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice(1).paymentHash, PaymentType.Standard, 123 msat, 123 msat, aliceNodeId, 1100 unixms, Some(invoice(0)), Pending)) assert(nodeParams.db.payments.listOutgoingPayments(invoice(1).paymentHash).nonEmpty) - val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") // wait for SwapMaker and SwapTaker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -175,24 +192,26 @@ 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, aliceKeyManager, aliceDb, Set())), "SwapRegister") + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") swapEvents.expectNoMessage() userCli.expectNoMessage() // User:SwapInRequested -> SwapInRegister - swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + swapRegister ! SwapRequested(userCli.ref, Maker, amount, shortChannelId, None) + register.expectMessageType[ForwardShortId[CMD_GET_CHANNEL_INFO]].message.replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, channelId, NORMAL, ChannelCodecsSpec.normal) + assert(monitor.expectMessageType[SwapRequested].remoteNodeId.isEmpty) + assert(monitor.expectMessageType[SwapRequested].remoteNodeId.get == remoteNodeId) val swapId = userCli.expectMessageType[SwapOpened].swapId - monitor.expectMessageType[SwapInRequested] // Alice:SwapInRequest -> Bob - val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val swapInRequest = expectSwapMessage[SwapInRequest](switchboard) assert(swapId === swapInRequest.swapId) // Alice's database has no items before the opening tx is published assert(aliceDb.list().isEmpty) // Bob: SwapInAgreement -> Alice - swapRegister ! makePluginMessage(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, bobPayoutPubkey.toString(), premium)) + swapRegister ! makePluginMessage(peer, SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, bobPayoutPubkey.toString(), premium)) monitor.expectMessageType[WrappedUnknownMessageReceived] // Alice's database should be updated before the opening tx is published @@ -204,7 +223,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app swapEvents.expectMessageType[TransactionPublished] // Alice:OpeningTxBroadcasted -> Bob - val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val openingTxBroadcasted = expectSwapMessage[OpeningTxBroadcasted](switchboard) // Bob: payment(paymentHash) -> Alice val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get.paymentHash @@ -220,41 +239,40 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app testKit.stop(swapRegister) } - test("fail second swap request on same channel") { f => + test("fail subsequent swap requests 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() + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") // first swap request succeeds - swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + swapRegister ! SwapRequested(userCli.ref, Maker, amount, shortChannelId, None) + register.expectMessageType[ForwardShortId[CMD_GET_CHANNEL_INFO]].message.replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, channelId, NORMAL, ChannelCodecsSpec.normal) + val response = userCli.expectMessageType[SwapOpened] - val request = swapInRequestCodec.decode(expectUnknownMessage(register).data.toBitVector).require.value + val request = expectSwapMessage[SwapInRequest](switchboard) 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) + // swap requests from the user with the same channel id should fail + swapRegister ! SwapRequested(userCli.ref, Maker, amount, shortChannelId, None) userCli.expectMessageType[SwapExistsForChannel] - register.expectNoMessage() - swapRegister ! SwapOutRequested(userCli.ref, amount, shortChannelId) + swapRegister ! SwapRequested(userCli.ref, Taker, amount, shortChannelId, None) userCli.expectMessageType[SwapExistsForChannel] - register.expectNoMessage() - swapRegister ! makePluginMessage(swapInRequest) - register.expectNoMessage() + // swap requests from a peer with the same channel id should fail + swapRegister ! makePluginMessage(peer, swapInRequest) + expectCancelSwap(peer) - swapRegister ! makePluginMessage(swapOutRequest) - register.expectNoMessage() + swapRegister ! makePluginMessage(peer, swapOutRequest) + expectCancelSwap(peer) } test("list the active swaps in the register") { f => import f._ - 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 savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice(0), openingTxBroadcasted(0), swapRole = SwapRole.Maker, isInitiator = true, remoteNodeId), + SwapData(swapOutRequest, swapOutAgreement, invoice(1), openingTxBroadcasted(1), swapRole = SwapRole.Taker, isInitiator = true, remoteNodeId)) val nodeParams = TestConstants.Alice.nodeParams // add pending outgoing payment to the payments databases @@ -262,7 +280,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice(1).paymentHash, PaymentType.Standard, 123 msat, 123 msat, aliceNodeId, 1100 unixms, Some(invoice(0)), Pending)) assert(nodeParams.db.payments.listOutgoingPayments(invoice(1).paymentHash).nonEmpty) - val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") val listCli = TestProbe[Iterable[Response]]() swapRegister ! ListPendingSwaps(listCli.ref) diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala index b43bcc5ad6..25e3261a89 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala @@ -50,6 +50,7 @@ class SwapsDbSpec extends AnyFunSuite { val paymentPreimage: ByteVector32 = ByteVector32.One val feePreimage: ByteVector32 = ByteVector32.Zeroes val scid = "1x1x1" + val remoteNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId 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)) @@ -63,14 +64,14 @@ class SwapsDbSpec extends AnyFunSuite { 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 = { + def swapData(swapId: String, isInitiator: Boolean, swapType: SwapRole, remoteNodeId: PublicKey): 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) + SwapData(request, agreement, paymentInvoice(swapId), openingTxBroadcasted(swapId), swapType, isInitiator, remoteNodeId) } test("init database two times in a row") { @@ -83,10 +84,10 @@ class SwapsDbSpec extends AnyFunSuite { 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, remoteNodeId) + val swap_2 = swapData(randomBytes32().toString(),isInitiator = false, Maker, remoteNodeId) + val swap_3 = swapData(randomBytes32().toString(),isInitiator = true, Taker, remoteNodeId) + val swap_4 = swapData(randomBytes32().toString(),isInitiator = false, Taker, remoteNodeId) assert(db.list().toSet == Set.empty) db.add(swap_1) @@ -111,7 +112,7 @@ class SwapsDbSpec extends AnyFunSuite { 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))) + Future(db.add(swapData(randomBytes32().toString(),isInitiator = true, Maker, remoteNodeId))) } val res = Future.sequence(futures) Await.result(res, 60 seconds) diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala index f58b495225..30d598dc43 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala @@ -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.plugins.peerswap.SwapHelpers.checkSpendable import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.checkSpendable import grizzled.slf4j.Logging import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike From f9b74639492b31ffd96c0d96ba4d3f8f283dde0a Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Mon, 5 Dec 2022 09:03:00 +0100 Subject: [PATCH 23/32] Add find function to swap db - remove unnecessary selects of swapId --- .../eclair/plugins/peerswap/db/DualSwapsDb.scala | 5 +++++ .../fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala | 1 + .../eclair/plugins/peerswap/db/pg/PgSwapsDb.scala | 11 ++++++++++- .../plugins/peerswap/db/sqlite/SqliteSwapsDb.scala | 11 +++++++++-- .../eclair/plugins/peerswap/db/SwapsDbSpec.scala | 7 +++++-- 5 files changed, 30 insertions(+), 5 deletions(-) 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 index 54ec84eae3..38178d3154 100644 --- 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 @@ -52,4 +52,9 @@ case class DualSwapsDb(primary: SwapsDb, secondary: SwapsDb) extends SwapsDb { runAsync(secondary.list()) primary.list() } + + override def find(swapId: String): Option[SwapData] = { + runAsync(secondary.find(swapId)) + primary.find(swapId) + } } \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala index f93cacd23b..904576d2e9 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala @@ -40,6 +40,7 @@ trait SwapsDb { def list(): Seq[SwapData] + def find(swapId: String): Option[SwapData] } object SwapsDb { diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala index 3b7ef9228a..cf21ae30cc 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala @@ -82,7 +82,7 @@ class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { 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, remote_node_id, result FROM swaps WHERE result=?")) { statement => + using(pg.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result FROM swaps WHERE result=?")) { statement => statement.setString(1, "") statement.executeQuery().map(rs => getSwapData(rs)).toSeq } @@ -97,4 +97,13 @@ class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { } } + override def find(swapId: String): Option[SwapData] = withMetrics("swaps/find", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result FROM swaps WHERE swap_id=?")) { statement => + statement.setString(1, swapId) + statement.executeQuery().map(rs => getSwapData(rs)).toSeq.headOption + } + } + } + } \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala index 1088db157c..6625227d1b 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala @@ -72,16 +72,23 @@ class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { } 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, remote_node_id, result FROM swaps WHERE result=?")) { statement => + using(sqlite.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, 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, remote_node_id, result FROM swaps")) { statement => + using(sqlite.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result FROM swaps")) { statement => statement.executeQuery().map(rs => getSwapData(rs)).toSeq } } + override def find(swapId: String): Option[SwapData] = withMetrics("swaps/find", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, remote_node_id, result FROM swaps WHERE swap_id=?")) { statement => + statement.setString(1, swapId) + statement.executeQuery().map(rs => getSwapData(rs)).toSeq.headOption + } + } + } \ No newline at end of file diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala index 25e3261a89..54f7ac3da9 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala @@ -80,7 +80,7 @@ class SwapsDbSpec extends AnyFunSuite { new SqliteSwapsDb(connection) } - test("add/list/addResult/restore/remove swaps") { + test("add/list/find/addResult/restore/remove swaps") { val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) assert(db.list().isEmpty) @@ -88,6 +88,7 @@ class SwapsDbSpec extends AnyFunSuite { val swap_2 = swapData(randomBytes32().toString(),isInitiator = false, Maker, remoteNodeId) val swap_3 = swapData(randomBytes32().toString(),isInitiator = true, Taker, remoteNodeId) val swap_4 = swapData(randomBytes32().toString(),isInitiator = false, Taker, remoteNodeId) + val swap_5 = swapData(randomBytes32().toString(),isInitiator = false, Taker, remoteNodeId) assert(db.list().toSet == Set.empty) db.add(swap_1) @@ -99,10 +100,12 @@ class SwapsDbSpec extends AnyFunSuite { db.add(swap_3) db.add(swap_4) assert(db.list().toSet == Set(swap_1, swap_2, swap_3, swap_4)) + assert(Set(swap_1, swap_2, swap_3, swap_4).map( s => db.find(s.swapId).get) == Set(swap_1, swap_2, swap_3, swap_4)) + assert(db.find(swap_5.swapId).isEmpty) db.addResult(paymentCompleteResult(swap_2.request.swapId)) assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) db.remove(swap_3.request.swapId) - assert(db.list().size == 3) // include resolved swap_2 + assert(db.list().toSet == Set(swap_1, db.find(swap_2.swapId).get, swap_4)) assert(db.restore().toSet == Set(swap_1, swap_4)) } From cc1e298d469c4c923d865acfefd083148119397e Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Mon, 5 Dec 2022 09:07:22 +0100 Subject: [PATCH 24/32] Query payment db for payment before waiting for event --- .../eclair/plugins/peerswap/SwapEvents.scala | 5 +- .../eclair/plugins/peerswap/SwapMaker.scala | 48 ++++++++++--------- .../plugins/peerswap/SwapInSenderSpec.scala | 4 +- .../peerswap/SwapOutReceiverSpec.scala | 2 +- .../plugins/peerswap/db/SwapsDbSpec.scala | 3 +- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala index 5055143ff7..4a044773d2 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala @@ -18,7 +18,6 @@ package fr.acinq.eclair.plugins.peerswap 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 { @@ -33,8 +32,8 @@ object SwapEvents { case class ClaimByCoopOffered(swapId: String, reason: String) extends SwapEvent { override def toString: String = s"Coop close offered to peer: $reason" } - case class ClaimByInvoicePaid(swapId: String, payment: PaymentReceived) extends SwapEvent { - override def toString: String = s"Invoice payment received: $payment" + case class ClaimByInvoicePaid(swapId: String) extends SwapEvent { + override def toString: String = s"Invoice payment received" } case class ClaimByCoopConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { override def toString: String = s"Claimed by coop: $confirmation" 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 d08deda246..c99e765b57 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 @@ -29,6 +29,7 @@ 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.db.IncomingPaymentStatus.Received import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceiveStandardPayment} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.plugins.peerswap.SwapCommands._ @@ -239,29 +240,32 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } } - 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(switchboard, remoteNodeId)(openingTxBroadcasted) // send message to peer about opening tx broadcast - - Behaviors.withTimers { timers => - timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) - receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { - // 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, isInitiator) - case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") - Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) - Behaviors.same - } + def awaitClaimPayment(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = + nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) match { + case Some(payment) if payment.status.isInstanceOf[Received] && payment.status.asInstanceOf[Received].amount >= request.amount.sat => + swapCompleted(ClaimByInvoicePaid(request.swapId)) + case _ => + watchForPayment(watch = true) + send(switchboard, remoteNodeId)(openingTxBroadcasted) + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { + // 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)) + 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, isInitiator) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } } - } def claimSwapCoop(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, coopClose: CoopClose, isInitiator: Boolean): Behavior[SwapCommand] = { val takerPrivkey = PrivateKey(ByteVector.fromValidHex(coopClose.privkey)) diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala index 2874d4ad98..c94145a478 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -136,7 +136,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo deathWatcher.expectTerminated(swapInSender) // the swap result has been recorded in the db - assert(db.list().head.result.contains("Invoice payment received:")) + assert(db.list().head.result.contains("Invoice payment received")) } test("happy path for new swap") { f => @@ -176,7 +176,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo testKit.stop(swapInSender) // the swap result has been recorded in the db - assert(db.list().head.result.contains("Invoice payment received:")) + assert(db.list().head.result.contains("Invoice payment received")) } test("claim refund by coop close path from restored swap") { f => diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala index ae29f6cdf0..03de2dcfe9 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -152,6 +152,6 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory testKit.stop(swapOutReceiver) // the swap result has been recorded in the db - assert(db.list().head.result.contains("Invoice payment received:")) + assert(db.list().head.result.contains("Invoice payment received")) } } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala index 54f7ac3da9..436357c5ba 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala @@ -63,7 +63,6 @@ class SwapsDbSpec extends AnyFunSuite { 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, remoteNodeId: PublicKey): SwapData = { val (request, agreement) = (isInitiator, swapType == Maker) match { case (true, true) => (swapInRequest(swapId), swapInAgreement(swapId)) @@ -102,7 +101,7 @@ class SwapsDbSpec extends AnyFunSuite { assert(db.list().toSet == Set(swap_1, swap_2, swap_3, swap_4)) assert(Set(swap_1, swap_2, swap_3, swap_4).map( s => db.find(s.swapId).get) == Set(swap_1, swap_2, swap_3, swap_4)) assert(db.find(swap_5.swapId).isEmpty) - db.addResult(paymentCompleteResult(swap_2.request.swapId)) + db.addResult(ClaimByInvoicePaid(swap_2.request.swapId)) assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) db.remove(swap_3.request.swapId) assert(db.list().toSet == Set(swap_1, db.find(swap_2.swapId).get, swap_4)) From d272226df23e6e7db0c40f4f92838cdac7c735f9 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Mon, 5 Dec 2022 09:14:11 +0100 Subject: [PATCH 25/32] Change to safe resume supervision strategy --- .../eclair/plugins/peerswap/SwapMaker.scala | 60 ++++++++++--------- .../plugins/peerswap/SwapRegister.scala | 6 +- .../eclair/plugins/peerswap/SwapTaker.scala | 41 +++++++------ .../plugins/peerswap/SwapInReceiverSpec.scala | 5 ++ .../plugins/peerswap/SwapInSenderSpec.scala | 3 + 5 files changed, 66 insertions(+), 49 deletions(-) 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 c99e765b57..4eacbac983 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 @@ -210,35 +210,39 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } } - def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = { - val receivePayment = ReceiveStandardPayment(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.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) - Behaviors.same - case OpeningTxFunded(invoice, fundingResponse) => - commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") - Behaviors.same - case OpeningTxCommitted(invoice, openingTxBroadcasted) => - db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator, remoteNodeId)) - 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(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(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 ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) - Behaviors.same + def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = + db.find(request.swapId) match { + case Some(s: SwapData) => + awaitClaimPayment(request, agreement, s.invoice, s.openingTxBroadcasted, isInitiator) + case None => + val receivePayment = ReceiveStandardPayment(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.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) + Behaviors.same + case OpeningTxFunded(invoice, fundingResponse) => + commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") + Behaviors.same + case OpeningTxCommitted(invoice, openingTxBroadcasted) => + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator, remoteNodeId)) + 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(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(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 ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + Behaviors.same + } } - } def awaitClaimPayment(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) match { 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 1a46e57c27..2446f3e2f0 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 @@ -88,8 +88,10 @@ private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParam private def spawnSwap(swapRole: SwapRole, remoteNodeId: PublicKey, scid: String) = { swapRole match { - case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(remoteNodeId, nodeParams, watcher, switchboard, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapMaker-" + scid) - case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(remoteNodeId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.stop), "SwapTaker-" + scid) + // swap maker is safe to resume because an opening transaction will only be funded once + case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(remoteNodeId, nodeParams, watcher, switchboard, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.resume), "SwapMaker-" + scid) + // swap taker is safe to resume because a payment will only be sent once + case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(remoteNodeId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db)).onFailure(typed.SupervisorStrategy.resume), "SwapTaker-" + scid) } } 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 1fc22a60ad..c724643f96 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 @@ -260,26 +260,29 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } } - def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { - Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { - case Failure(e) => sendCoopClose(request, s"Could not parse payreq: $e") - case Success(invoice) if invoice.amount_opt.isDefined && invoice.amount_opt.get > request.amount.sat.toMilliSatoshi => - sendCoopClose(request, s"Invoice amount ${invoice.amount_opt.get} > requested on-chain amount ${request.amount.sat.toMilliSatoshi}") - case Success(invoice) if invoice.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => - sendCoopClose(request, s"Channel hop other than $shortChannelId found in invoice hints ${invoice.routingInfo}") - case Success(invoice) if invoice.isExpired() => - sendCoopClose(request, s"Invoice is expired.") - case Success(invoice) if invoice.minFinalCltvExpiryDelta >= CltvExpiryDelta(claimByCsvDelta.toInt / 2) => - sendCoopClose(request, s"Invoice min-final-cltv-expiry delta too long.") - case Success(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => - // save restore point before a payment is initiated - db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator, remoteNodeId)) - payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) - payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, isInitiator) - case Success(_) => - sendCoopClose(request, s"Invalid opening tx: $openingTx") + def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = + db.find(request.swapId) match { + case Some(s: SwapData) => payClaimInvoice(request, agreement, openingTxBroadcasted, s.invoice, isInitiator) + case None => + Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { + case Failure(e) => sendCoopClose(request, s"Could not parse payreq: $e") + case Success(invoice) if invoice.amount_opt.isDefined && invoice.amount_opt.get > request.amount.sat.toMilliSatoshi => + sendCoopClose(request, s"Invoice amount ${invoice.amount_opt.get} > requested on-chain amount ${request.amount.sat.toMilliSatoshi}") + case Success(invoice) if invoice.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => + sendCoopClose(request, s"Channel hop other than $shortChannelId found in invoice hints ${invoice.routingInfo}") + case Success(invoice) if invoice.isExpired() => + sendCoopClose(request, s"Invoice is expired.") + case Success(invoice) if invoice.minFinalCltvExpiryDelta >= CltvExpiryDelta(claimByCsvDelta.toInt / 2) => + sendCoopClose(request, s"Invoice min-final-cltv-expiry delta too long.") + case Success(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => + // save restore point before a payment is initiated + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator, remoteNodeId)) + payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) + payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, isInitiator) + case Success(_) => + sendCoopClose(request, s"Invalid opening tx: $openingTx") + } } - } def payClaimInvoice(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, isInitiator: Boolean): Behavior[SwapCommand] = { watchForPayment(watch = true) // subscribe to payment event notifications diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala index b7e4efba30..2aad1ddaa7 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -128,6 +128,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // the swap result has been recorded in the db assert(db.list().head.result.contains("Coop close offered to peer: Lightning payment not sent.")) + db.remove(swapId) } test("send cooperative close after a restore with the payment already marked as failed") { f => @@ -153,6 +154,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // the swap result has been recorded in the db assert(db.list().head.result.contains("Coop close offered to peer: Lightning payment failed")) + db.remove(swapId) } test("claim by invoice after a restore with the payment already marked as paid") { f => @@ -189,6 +191,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // the swap result has been recorded in the db assert(db.list().head.result.contains("Claimed by paid invoice:")) + db.remove(swapId) } test("claim by invoice after a restore with the payment marked as pending and later paid") { f => @@ -233,6 +236,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // the swap result has been recorded in the db assert(db.list().head.result.contains("Claimed by paid invoice:")) + db.remove(swapId) } test("happy path for new swap in") { f => @@ -289,6 +293,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // the swap result has been recorded in the db assert(db.list().head.result.contains("Claimed by paid invoice:")) + db.remove(swapId) } test("invalid invoice, min_final-cltv-expiry of invoice greater than the claim-by-csv delta") { f => diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala index c94145a478..1d95979add 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -137,6 +137,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // the swap result has been recorded in the db assert(db.list().head.result.contains("Invoice payment received")) + db.remove(swapId) } test("happy path for new swap") { f => @@ -177,6 +178,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // the swap result has been recorded in the db assert(db.list().head.result.contains("Invoice payment received")) + db.remove(swapId) } test("claim refund by coop close path from restored swap") { f => @@ -217,6 +219,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // the swap result has been recorded in the db assert(db.list().head.result.contains("Claimed by coop")) + db.remove(swapId) } test("claim refund by csv path from restored swap") { f => From c2c846075e6c70dc57290aab96e9b9da66c83dc8 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 9 Dec 2022 11:43:30 +0100 Subject: [PATCH 26/32] Clean up responses and commands - remove inline text for generic responses - remove unused commands (timeout, forward) --- .../plugins/peerswap/SwapCommands.scala | 30 ++--- .../eclair/plugins/peerswap/SwapHelpers.scala | 35 ++--- .../eclair/plugins/peerswap/SwapMaker.scala | 82 +++++------- .../plugins/peerswap/SwapResponses.scala | 113 +++++++++++++--- .../eclair/plugins/peerswap/SwapTaker.scala | 126 ++++++++---------- .../plugins/peerswap/SwapInReceiverSpec.scala | 12 +- .../peerswap/SwapIntegrationSpec.scala | 10 +- 7 files changed, 221 insertions(+), 187 deletions(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala index db55728fd9..8d5859235f 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala @@ -20,12 +20,10 @@ import akka.actor.typed.ActorRef 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.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchTxConfirmedTriggered} import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} 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 { @@ -36,27 +34,22 @@ object SwapCommands { case class StartSwapOutReceiver(request: SwapOutRequest) extends SwapCommand case class RestoreSwap(swapData: SwapData) 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 - 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 AwaitFeePaymentMessages with AwaitOpeningTxConfirmedMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages - case class ForwardFailureAdapter(result: Register.ForwardFailure[UnknownMessage]) 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 + case class OpeningTxFundingFailed(cause: Throwable) extends CreateOpeningTxMessages + case class OpeningTxCommitFailed(fundingResponse: MakeFundingTxResponse) extends CreateOpeningTxMessages + case class RollbackSuccess(status: Boolean, fundingResponse: MakeFundingTxResponse) extends CreateOpeningTxMessages + case class RollbackFailure(exception: Throwable, fundingResponse: MakeFundingTxResponse) extends CreateOpeningTxMessages sealed trait AwaitOpeningTxConfirmedMessages extends SwapCommand case class OpeningTxConfirmed(openingConfirmedTriggered: WatchTxConfirmedTriggered) extends AwaitOpeningTxConfirmedMessages with ClaimSwapCoopMessages - case object InvoiceExpired extends AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with AwaitFeePaymentMessages + case object InvoiceExpired extends AwaitClaimPaymentMessages with AwaitFeePaymentMessages sealed trait AwaitClaimPaymentMessages extends SwapCommand case class CsvDelayConfirmed(csvDelayTriggered: WatchFundingDeeplyBuriedTriggered) extends SwapCommand with WaitCsvMessages @@ -64,10 +57,9 @@ object SwapCommands { 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 object ClaimTxFailed extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + case object ClaimTxInvalid 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 @@ -79,18 +71,14 @@ object SwapCommands { sealed trait SendAgreementMessages extends SwapCommand sealed trait AwaitFeePaymentMessages extends SwapCommand - case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[UnknownMessage]) extends AwaitFeePaymentMessages with SendCoopCloseMessages with SendAgreementMessages sealed trait PayClaimInvoiceMessages extends SwapCommand - sealed trait SendCoopCloseMessages extends SwapCommand - case class OpeningTxOutputSpent(openingTxOutputSpentTriggered: WatchOutputSpentTriggered) extends SendCoopCloseMessages - sealed trait ClaimSwapMessages extends SwapCommand sealed trait PayFeeInvoiceMessages extends SwapCommand - sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with SendCoopCloseMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages + sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages 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/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 78b7ebc07a..5363eeb96b 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 @@ -46,7 +46,7 @@ import scala.concurrent.ExecutionContext.Implicits.global import scala.reflect.ClassTag import scala.util.{Failure, Success, Try} -object SwapHelpers { +private object SwapHelpers { 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}") @@ -75,14 +75,14 @@ object SwapHelpers { 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) + private def paymentEventAdapter(context: ActorContext[SwapCommand]): ActorRef[PaymentEvent] = context.messageAdapter[PaymentEvent](PaymentEventReceived) def makeUnknownMessage(message: HasSwapId): UnknownMessage = { val encoded = peerSwapMessageCodecWithFallback.encode(message).require UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.drop(16).toByteVector) } - def send(switchboard: actor.ActorRef, remoteNodeId: PublicKey)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + def send(switchboard: actor.ActorRef, remoteNodeId: PublicKey)(message: HasSwapId): Unit = switchboard ! ForwardUnknownMessage(remoteNodeId, makeUnknownMessage(message)) def fundOpening(wallet: OnChainWallet, feeRatePerKw: FeeratePerKw)(amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand]): Unit = { @@ -91,17 +91,20 @@ object SwapHelpers { // funding successful, commit the opening tx 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") + case Failure(cause) => OpeningTxFundingFailed(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) => context.log.debug(s"opening tx ${fundingResponse.fundingTx.txid} published for swap $swapId") + 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) => OpeningTxCommitFailed(fundingResponse) + case Failure(t) => + context.log.debug(s"opening tx ${fundingResponse.fundingTx.txid} *possibly* published for swap $swapId, exception: $t ") 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)) } } @@ -117,19 +120,19 @@ object SwapHelpers { context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, txInfo.tx, desc)) context.pipeToSelf(wallet.commit(txInfo.tx)) { case Success(true) => ClaimTxCommitted - 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 Success(false) => context.log.error(s"swap $swapId claim tx commit did not succeed with $txInfo") + ClaimTxFailed + case Failure(t) => context.log.error(s"swap $swapId claim tx commit *possibly* failed with $txInfo, exception: $t") + ClaimTxFailed } case Failure(e) => context.log.error(s"swap $swapId claim tx is invalid: $e") - context.self ! ClaimTxInvalid(e) + context.self ! ClaimTxInvalid } - 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) + def rollback(wallet: OnChainWallet)(fundingResponse: MakeFundingTxResponse)(implicit context: ActorContext[SwapCommand]): Unit = + context.pipeToSelf(wallet.rollback(fundingResponse.fundingTx)) { + case Success(status) => RollbackSuccess(status, fundingResponse) + case Failure(t) => RollbackFailure(t, fundingResponse) } def createInvoice(nodeParams: NodeParams, amount: Satoshi, description: String)(implicit context: ActorContext[SwapCommand]): Try[Bolt11Invoice] = 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 4eacbac983..0189eb5208 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 @@ -35,7 +35,7 @@ import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} 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.{CreateInvoiceFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} +import fr.acinq.eclair.plugins.peerswap.SwapResponses._ import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.db.SwapsDb @@ -158,7 +158,7 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private 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.")) + swapCanceled(IncompatibleRequest(request.swapId, 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) @@ -173,15 +173,13 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, Behaviors.withTimers { timers => timers.startSingleTimer(swapFeeExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) - receiveSwapMessage[AwaitFeePaymentMessages](context, "sendAgreement") { + receiveSwapMessage[AwaitFeePaymentMessages](context, "awaitFeePayment") { 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, 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")) - case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) + case InvoiceExpired => swapCanceled(FeePaymentInvoiceExpired(request.swapId)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) @@ -195,13 +193,11 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { case SwapMessageReceived(agreement: SwapInAgreement) if agreement.protocolVersion != protocolVersion => - swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) + swapCanceled(WrongVersion(request.swapId, protocolVersion)) case SwapMessageReceived(agreement: SwapInAgreement) if agreement.premium > maxPremium => - swapCanceled(InternalError(request.swapId, "unacceptable premium requested.")) + swapCanceled(PremiumRejected(request.swapId)) case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement, isInitiator = true) 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)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) @@ -210,12 +206,12 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } } - def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = + private def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = db.find(request.swapId) match { case Some(s: SwapData) => awaitClaimPayment(request, agreement, s.invoice, s.openingTxBroadcasted, isInitiator) case None => - val receivePayment = ReceiveStandardPayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left("send-swap-in")) + val receivePayment = ReceiveStandardPayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left(s"swap ${request.swapId}")) val createInvoice = context.spawnAnonymous(CreateInvoiceActor(nodeParams)) createInvoice ! CreateInvoiceActor.CreateInvoice(context.messageAdapter[Bolt11Invoice](InvoiceResponse).toClassic, receivePayment) @@ -223,28 +219,25 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case InvoiceResponse(invoice: Bolt11Invoice) => fundOpening(wallet, feeRatePerKw)((request.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) Behaviors.same case OpeningTxFunded(invoice, fundingResponse) => - commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") + commitOpening(wallet)(request.swapId, invoice, fundingResponse, s"swap ${request.swapId} opening tx") Behaviors.same case OpeningTxCommitted(invoice, openingTxBroadcasted) => db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator, remoteNodeId)) 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) + case OpeningTxFundingFailed(cause) => swapCanceled(OpeningFundingFailed(request.swapId, cause)) + case OpeningTxCommitFailed(r) => rollback(wallet)(r) Behaviors.same - 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 RollbackSuccess(value, r) => swapCanceled(OpeningCommitFailed(request.swapId, value, r)) + case RollbackFailure(t, r) => swapCanceled(OpeningRollbackFailed(request.swapId, r, t)) 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(request.swapId, "timeout during CreateOpeningTx")) - case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same // ignore case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) Behaviors.same } } - def awaitClaimPayment(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = + private def awaitClaimPayment(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) match { case Some(payment) if payment.status.isInstanceOf[Received] && payment.status.asInstanceOf[Received].amount >= request.amount.sat => swapCompleted(ClaimByInvoicePaid(request.swapId)) @@ -263,7 +256,7 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case SwapMessageReceived(_) => Behaviors.same case InvoiceExpired => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) - case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same @@ -271,7 +264,7 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } } - def claimSwapCoop(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, coopClose: CoopClose, isInitiator: Boolean): Behavior[SwapCommand] = { + private 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(request.swapId), takerPrivkey, invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) @@ -284,21 +277,21 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { case OpeningTxConfirmed(_) => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) - commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") + commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "claim_by_coop") Behaviors.same case ClaimTxCommitted => Behaviors.same case ClaimTxConfirmed(confirmedTriggered) => 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.") + case ClaimTxFailed => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case ClaimTxInvalid => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } - def waitCsv(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { + private 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) @@ -307,56 +300,45 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, receiveSwapMessage[WaitCsvMessages](context, "waitCsv") { case CsvDelayConfirmed(_) => claimSwapCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) - case StateTimeout => - // TODO: problem with the blockchain monitor? - Behaviors.same - case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } - def claimSwapCsv(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { + private 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(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)(request.swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "swap-in-sender-claimByCsvTx") + commitClaim(wallet)(request.swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "claim_by_csv") receiveSwapMessage[ClaimSwapCsvMessages](context, "claimSwapCsv") { case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCsvConfirmedAdapter, claimByCsvTx.txid, nodeParams.channelConf.minDepthBlocks) Behaviors.same 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(request.swapId, "Can not cancel swap after opening tx committed.") + case ClaimTxFailed => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case ClaimTxInvalid => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) Behaviors.same } } - def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + private def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { context.system.eventStream ! Publish(event) context.log.info(s"completed swap: $event.") db.addResult(event) Behaviors.stopped } - def swapCanceled(failure: Fail): Behavior[SwapCommand] = { - val swapEvent = Canceled(failure.swapId, failure.toString) - context.system.eventStream ! Publish(swapEvent) + private def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(Canceled(failure.swapId, failure.toString)) + context.log.error(s"canceled swap: $failure") if (!failure.isInstanceOf[PeerCanceled]) send(switchboard, remoteNodeId)(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 $failure.swapId, reason: unknown.") - } Behaviors.stopped } 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 e7d2ac3005..ec6b5b2ba1 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 @@ -16,7 +16,12 @@ package fr.acinq.eclair.plugins.peerswap -import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.bitcoin.scalacompat.{Satoshi, Transaction} +import fr.acinq.eclair.{MilliSatoshi, ShortChannelId} +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse +import fr.acinq.eclair.db.OutgoingPaymentStatus.Failed +import fr.acinq.eclair.payment.Bolt11Invoice.ExtraHop +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapAgreement, SwapRequest} object SwapResponses { @@ -27,14 +32,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 + sealed trait Status extends Response + + case class SwapOpened(swapId: String) extends Success { + override def toString: String = s"swap $swapId opened successfully." + } + case class SwapExistsForChannel(shortChannelId: String) extends Fail { override def swapId: String = "" override def toString: String = s"swap already exists for channel $shortChannelId" @@ -44,11 +51,11 @@ object SwapResponses { override def toString: String = s"swap $swapId not found." } - case class UserCanceled(swapId: String) extends Fail { + case class UserCanceled(swapId: String) extends Error { override def toString: String = s"swap $swapId canceled by user." } - case class PeerCanceled(swapId: String, reason: String) extends Fail { + case class PeerCanceled(swapId: String, reason: String) extends Error { override def toString: String = s"swap $swapId canceled by peer, reason: $reason." } @@ -56,23 +63,99 @@ object SwapResponses { override def toString: String = s"could not create swap: $reason." } - case class CreateInvoiceFailed(swapId: String, throwable: Throwable) extends Fail { - override def toString: String = s"swap $swapId canceled, could not create invoice: $throwable" + case class CreateInvoiceFailed(swapId: String, exception: Throwable) extends Error { + override def toString: String = s"swap $swapId canceled, could not create invoice: ${exception.getMessage}" } - case class InvalidMessage(swapId: String, behavior: String, message: HasSwapId) extends Fail { + case class InvalidMessage(swapId: String, behavior: String, message: HasSwapId) extends Error { override def toString: String = s"swap $swapId canceled due to invalid message during $behavior: $message." } - case class SwapError(swapId: String, reason: String) extends Error { - override def toString: String = s"swap $swapId error: $reason." + case class CancelAfterOpeningCommit(swapId: String) extends Error { + override def toString: String = "Can not cancel swap after opening tx is committed." } - case class InternalError(swapId: String, reason: String) extends Error { - override def toString: String = s"swap $swapId internal error: $reason." + case class CancelAfterClaimCommit(swapId: String) extends Error { + override def toString: String = "Can not cancel swap after claim tx is committed." } - sealed trait Status extends Response + case class IncompatibleRequest(swapId: String, request: SwapRequest) extends Error { + override def toString: String = s"incompatible request: $request" + } + + case class FeePaymentInvoiceExpired(swapId: String) extends Error { + override def toString: String = s"fee payment invoice expired" + } + + case class WrongVersion(swapId: String, version: Int) extends Error { + override def toString: String = s"protocol version must be $version" + } + + case class PremiumRejected(swapId: String) extends Error { + override def toString: String = s"unacceptable premium requested." + } + + case class OpeningFundingFailed(swapId: String, cause: Throwable) extends Error { + override def toString: String = s"failed to fund swap open tx, cause: ${cause.getMessage}" + } + + case class OpeningCommitFailed(swapId: String, rollback: Boolean, fundingResponse: MakeFundingTxResponse) extends Error { + override def toString: String = s"failed to commit swap open tx: ${fundingResponse.fundingTx}, rollback=$rollback" + } + + case class OpeningRollbackFailed(swapId: String, fundingResponse: MakeFundingTxResponse, exception: Throwable) extends Error { + override def toString: String = s"failed to commit swap open tx: ${fundingResponse.fundingTx}, rollback exception: ${exception.getMessage}" + } + + case class InvalidFeeInvoiceAmount(swapId: String, amount: Option[MilliSatoshi], maxOpeningFee: Satoshi) extends Error { + override def toString: String = amount match { + case Some(a) => s"fee invoice amount $a > estimated opening tx fee: $maxOpeningFee" + case None => s"fee invoice amount is missing or invalid" + } + } + + case class InvalidSwapInvoiceAmount(swapId: String, amount: Option[MilliSatoshi], requestedAmount: Satoshi) extends Error { + override def toString: String = amount match { + case Some(a) => s"swap invoice amount ${a.truncateToSatoshi} > requested amount: $requestedAmount" + case None => s"swap invoice amount is missing or invalid" + } + } + + case class InvalidSwapInvoiceExpiryDelta(swapId: String) extends Error { + override def toString: String = s"Invoice min-final-cltv-expiry delta too long." + } + + case class InvalidInvoiceChannel(swapId: String, shortChannelId: ShortChannelId, routingInfo: Seq[Seq[ExtraHop]], desc: String) extends Error { + override def toString: String = s"$desc invoice contains channel other than $shortChannelId in invoice hints $routingInfo" + } + + case class SwapPaymentInvoiceExpired(swapId: String) extends Error { + override def toString: String = s"swap payment invoice expired" + } + + case class FeeInvoiceInvalid(swapId: String, exception: Throwable) extends Error { + override def toString: String = s"could not parse fee invoice payreq: ${exception.getMessage}" + } + + case class SwapInvoiceInvalid(swapId: String, exception: Throwable) extends Error { + override def toString: String = s"could not parse swap invoice payreq: ${exception.getMessage}" + } + + case class OpeningTxInvalid(swapId: String, openingTx: Transaction) extends Error { + override def toString: String = s"opening tx invalid: $openingTx" + } + + case class LightningPaymentFailed(swapId: String, payment: Either[PaymentEvent, Failed], desc: String) extends Error { + override def toString: String = s"$desc lightning payment failed: $payment" + } + + case class SwapPaymentNotSent(swapId: String) extends Error { + override def toString: String = s"swap lightning payment not sent" + } + + case class UserRequestedCancel(swapId: String) extends Error { + override def toString: String = s"cancel requested by user" + } 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/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 c724643f96..20f8974e27 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 @@ -28,17 +28,17 @@ 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.db.OutgoingPaymentStatus.{Failed, Pending, Succeeded} -import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentFailed, PaymentSent} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentSent} 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.SwapResponses._ import fr.acinq.eclair.plugins.peerswap.SwapRole.Taker import fr.acinq.eclair.plugins.peerswap.db.SwapsDb import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ import fr.acinq.eclair.plugins.peerswap.wire.protocol._ -import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, ShortChannelId, ToMilliSatoshiConversion} +import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, ShortChannelId} import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt @@ -124,11 +124,11 @@ object SwapTaker { // handle a payment that has already succeeded, failed or is still pending nodeParams.db.payments.listOutgoingPayments(d.invoice.paymentHash).collectFirst { case p if p.status.isInstanceOf[Succeeded] => swap.claimSwap(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, p.status.asInstanceOf[Succeeded].paymentPreimage, d.isInitiator) - case p if p.status.isInstanceOf[Failed] => swap.sendCoopClose(d.request, s"Lightning payment failed: ${p.status.asInstanceOf[Failed].failures}") + case p if p.status.isInstanceOf[Failed] => swap.sendCoopClose(LightningPaymentFailed(d.request.swapId, Right(p.status.asInstanceOf[Failed]), "swap")) case p if p.status == Pending => swap.payClaimInvoice(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, d.isInitiator) }.getOrElse( // if payment was not yet sent, fail the swap - swap.sendCoopClose(d.request, s"Lightning payment not sent.") + swap.sendCoopClose(SwapPaymentNotSent(d.request.swapId)) ) case Failure(e) => context.log.error(s"Could not restore from a checkpoint with an invalid shortChannelId: $d, $e") db.addResult(CouldNotRestore(d.swapId, d)) @@ -167,11 +167,9 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { case SwapMessageReceived(agreement: SwapOutAgreement) if agreement.protocolVersion != protocolVersion => - swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) + swapCanceled(WrongVersion(request.swapId, protocolVersion)) case SwapMessageReceived(agreement: SwapOutAgreement) => validateFeeInvoice(request, agreement) 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)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) @@ -180,46 +178,41 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } } - def validateFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement): Behavior[SwapCommand] = { + private 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.amount_opt.isEmpty || i.amount_opt.get > maxOpeningFee => + swapCanceled(InvalidFeeInvoiceAmount(request.swapId, i.amount_opt, 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}")) + swapCanceled(InvalidInvoiceChannel(request.swapId, shortChannelId, i.routingInfo, "fee")) 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.")) + swapCanceled(FeePaymentInvoiceExpired(request.swapId)) case Success(feeInvoice) => payFeeInvoice(request, agreement, feeInvoice) - case Failure(e) => swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Could not parse payreq: $e")) + case Failure(e) => swapCanceled(FeeInvoiceInvalid(request.swapId, e)) } } - def payFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement, feeInvoice: Bolt11Invoice): Behavior[SwapCommand] = { + private 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") { + receiveSwapMessage[PayFeeInvoiceMessages](context, "payFeeInvoice") { // 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 PaymentEventReceived(p: PaymentEvent) => swapCanceled(LightningPaymentFailed(request.swapId, Left(p), "fee")) case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) 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 SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "payFeeInvoice", m)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) - swapCanceled(CreateFailed(request.swapId, s"Cancel requested by user while validating opening tx.")) + swapCanceled(UserCanceled(request.swapId)) case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) Behaviors.same } } - def validateRequest(request: SwapInRequest): Behavior[SwapCommand] = { - // fail if swap request is invalid, otherwise respond with agreement + private def validateRequest(request: SwapInRequest): Behavior[SwapCommand] = { if (request.protocolVersion != protocolVersion || request.asset != noAsset || request.network != NodeParams.chainFromHash(nodeParams.chainHash)) { - swapCanceled(InternalError(request.swapId, s"incompatible request: $request.")) + swapCanceled(IncompatibleRequest(request.swapId, request)) } else { sendAgreement(request, SwapInAgreement(protocolVersion, request.swapId, takerPubkey(request.swapId).toHex, premium.toLong)) } @@ -232,17 +225,15 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = false) 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.")) + case SwapMessageReceived(m) => sendCoopClose(InvalidMessage(request.swapId, "sendAgreement", m)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) - sendCoopClose(request, s"Cancel requested by user after sending agreement.") + sendCoopClose(UserRequestedCancel(request.swapId)) case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) Behaviors.same } } - def awaitOpeningTxConfirmed(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { + private 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 @@ -250,103 +241,90 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case OpeningTxConfirmed(opening) => validateOpeningTx(request, agreement, openingTxBroadcasted, opening.tx, isInitiator) case SwapMessageReceived(resend: OpeningTxBroadcasted) if resend == openingTxBroadcasted => Behaviors.same - 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 SwapMessageReceived(cancel: CancelSwap) => sendCoopClose(PeerCanceled(request.swapId, cancel.message)) + case SwapMessageReceived(m) => sendCoopClose(InvalidMessage(request.swapId, "awaitOpeningTxConfirmed", m)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) - sendCoopClose(request, s"Cancel requested by user while waiting for opening tx to confirm.") + sendCoopClose(UserRequestedCancel(request.swapId)) case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } - def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = + private def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = db.find(request.swapId) match { case Some(s: SwapData) => payClaimInvoice(request, agreement, openingTxBroadcasted, s.invoice, isInitiator) case None => Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { - case Failure(e) => sendCoopClose(request, s"Could not parse payreq: $e") - case Success(invoice) if invoice.amount_opt.isDefined && invoice.amount_opt.get > request.amount.sat.toMilliSatoshi => - sendCoopClose(request, s"Invoice amount ${invoice.amount_opt.get} > requested on-chain amount ${request.amount.sat.toMilliSatoshi}") + case Failure(e) => sendCoopClose(SwapInvoiceInvalid(request.swapId, e)) + case Success(invoice) if invoice.amount_opt.isEmpty || invoice.amount_opt.get > request.amount.sat => + sendCoopClose(InvalidSwapInvoiceAmount(request.swapId, invoice.amount_opt, request.amount.sat)) case Success(invoice) if invoice.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => - sendCoopClose(request, s"Channel hop other than $shortChannelId found in invoice hints ${invoice.routingInfo}") + sendCoopClose(InvalidInvoiceChannel(request.swapId, shortChannelId, invoice.routingInfo, "swap")) case Success(invoice) if invoice.isExpired() => - sendCoopClose(request, s"Invoice is expired.") + sendCoopClose(SwapPaymentInvoiceExpired(request.swapId)) case Success(invoice) if invoice.minFinalCltvExpiryDelta >= CltvExpiryDelta(claimByCsvDelta.toInt / 2) => - sendCoopClose(request, s"Invoice min-final-cltv-expiry delta too long.") + sendCoopClose(InvalidSwapInvoiceExpiryDelta(request.swapId)) case Success(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => - // save restore point before a payment is initiated db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator, remoteNodeId)) payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, isInitiator) - case Success(_) => - sendCoopClose(request, s"Invalid opening tx: $openingTx") + case Success(_) => sendCoopClose(OpeningTxInvalid(request.swapId, openingTx)) } } - def payClaimInvoice(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, isInitiator: Boolean): Behavior[SwapCommand] = { + private def payClaimInvoice(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, isInitiator: Boolean): Behavior[SwapCommand] = { watchForPayment(watch = true) // subscribe to payment event notifications receiveSwapMessage[PayClaimInvoiceMessages](context, "payClaimInvoice") { case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != invoice.paymentHash => Behaviors.same case PaymentEventReceived(p: PaymentSent) => claimSwap(request, agreement, openingTxBroadcasted, invoice, p.paymentPreimage, isInitiator) - case PaymentEventReceived(p: PaymentFailed) => sendCoopClose(request, s"Lightning payment failed: $p") - case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(request, s"Lightning payment failed (invalid PaymentEvent received: $p).") + case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(LightningPaymentFailed(request.swapId, Left(p), "swap")) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) - sendCoopClose(request, s"Cancel requested by user while paying claim invoice.") + sendCoopClose(UserCanceled(request.swapId)) case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } - def claimSwap(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, isInitiator: Boolean): Behavior[SwapCommand] = { + private def claimSwap(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, isInitiator: Boolean): Behavior[SwapCommand] = { val inputInfo = makeSwapOpeningInputInfo(openingTxBroadcasted.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, openingTxBroadcasted.txid, 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)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") + watchForPayment(watch = false) + commitClaim(wallet)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "claim_by_invoice") receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { case ClaimTxCommitted => Behaviors.same 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 $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 $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(request.swapId, "Can not cancel swap after claim tx committed.") - Behaviors.same // ignore + case ClaimTxFailed => Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? + case ClaimTxInvalid => Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? + case CancelRequested(replyTo) => replyTo ! CancelAfterClaimCommit(request.swapId) + Behaviors.same case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) Behaviors.same } } - def sendCoopClose(request: SwapRequest, reason: String): Behavior[SwapCommand] = { - context.log.error(s"swap ${request.swapId} sent coop close, reason: $reason") - send(switchboard, remoteNodeId)(CoopClose(request.swapId, reason, takerPrivkey(request.swapId).toHex)) - swapCompleted(ClaimByCoopOffered(request.swapId, reason)) + private def sendCoopClose(error: Error): Behavior[SwapCommand] = { + context.log.error(s"swap ${error.swapId} sent coop close, reason: ${error.toString}") + send(switchboard, remoteNodeId)(CoopClose(error.swapId, error.toString, takerPrivkey(error.swapId).toHex)) + swapCompleted(ClaimByCoopOffered(error.swapId, error.toString)) } - def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + private def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { context.system.eventStream ! Publish(event) context.log.info(s"completed swap: $event.") db.addResult(event) Behaviors.stopped } - def swapCanceled(failure: Fail): Behavior[SwapCommand] = { - 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 => send(switchboard, remoteNodeId)(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 ${failure.swapId}, reason: unknown.") - } + private def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(Canceled(failure.swapId, failure.toString)) + context.log.error(s"canceled swap: $failure") + if (!failure.isInstanceOf[PeerCanceled]) send(switchboard, remoteNodeId)(CancelSwap(failure.swapId, failure.toString)) Behaviors.stopped } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala index 2aad1ddaa7..ee9c1df507 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -30,15 +30,15 @@ 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.db.OutgoingPaymentStatus.Pending +import fr.acinq.eclair.db.OutgoingPaymentStatus.{Failed, Pending} import fr.acinq.eclair.db.PaymentsDbSpec.alice import fr.acinq.eclair.db.{OutgoingPayment, OutgoingPaymentStatus, PaymentType} import fr.acinq.eclair.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentFailed, PaymentSent} 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.SwapEvents.{ClaimByCoopOffered, ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{LightningPaymentFailed, Status, SwapPaymentNotSent, SwapStatus} import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} @@ -127,7 +127,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. deathWatcher.expectTerminated(swapInReceiver) // the swap result has been recorded in the db - assert(db.list().head.result.contains("Coop close offered to peer: Lightning payment not sent.")) + assert(db.list().head.result == ClaimByCoopOffered(swapId, SwapPaymentNotSent(swapId).toString()).toString) db.remove(swapId) } @@ -143,7 +143,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // add failed outgoing payment to the payments databases val paymentId = UUID.randomUUID() nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice.paymentHash, PaymentType.Standard, 123 msat, 123 msat, alice, 1100 unixms, Some(invoice), Pending)) - nodeParams.db.payments.updateOutgoingPayment(PaymentFailed(paymentId, invoice.paymentHash, Seq())) + nodeParams.db.payments.updateOutgoingPayment(PaymentFailed(paymentId, invoice.paymentHash, Seq(), 0 unixms)) assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) swapInReceiver ! RestoreSwap(swapData) @@ -153,7 +153,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. deathWatcher.expectTerminated(swapInReceiver) // the swap result has been recorded in the db - assert(db.list().head.result.contains("Coop close offered to peer: Lightning payment failed")) + assert(db.list().head.result == ClaimByCoopOffered(swapId, LightningPaymentFailed(swapId, Right(Failed(Seq(), 0 unixms)), "swap").toString()).toString) db.remove(swapId) } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala index 1d16df7a05..55ac4b87fd 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -16,7 +16,7 @@ import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentSent} import fr.acinq.eclair.plugins.peerswap.SwapEvents._ import fr.acinq.eclair.plugins.peerswap.SwapIntegrationFixture.swapRegister import fr.acinq.eclair.plugins.peerswap.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapRequested} -import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{OpeningFundingFailed, PeerCanceled, Status, SwapOpened} import fr.acinq.eclair.plugins.peerswap.SwapRole.{Maker, Taker} import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.openingTxWeight @@ -55,7 +55,7 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { fixture.cleanup() } - def swapActors(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): (SwapActors, SwapActors) = { + def swapActors(alice: MinimalNodeFixture, bob: MinimalNodeFixture): (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]) @@ -325,11 +325,11 @@ class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { // swap in sender (bob) requests a swap in with swap in receiver (alice) bobSwap.swapRegister ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) - bobSwap.cli.expectMsgType[SwapOpened] + val swap = bobSwap.cli.expectMsgType[SwapOpened] // both parties publish that the swap was canceled because bob could not fund the opening tx - assert(aliceSwap.swapEvents.expectMsgType[Canceled].reason.contains("error while funding swap open tx")) - assert(bobSwap.swapEvents.expectMsgType[Canceled].reason.contains("error while funding swap open tx")) + assert(aliceSwap.swapEvents.expectMsgType[Canceled].reason == PeerCanceled(swap.swapId,OpeningFundingFailed(swap.swapId, new RuntimeException("insufficient funds")).toString).toString) + assert(bobSwap.swapEvents.expectMsgType[Canceled].reason == OpeningFundingFailed(swap.swapId, new RuntimeException("insufficient funds")).toString) // both parties have no pending swaps bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) From ad33a79074fc1151abea0820d0cc7c67821fb15d Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Mon, 12 Dec 2022 10:23:57 +0100 Subject: [PATCH 27/32] Fixed to subscribe to specific payments events - reorganized because resume behavior should not need to check the swap db - removed monitor from tests --- .../plugins/peerswap/SwapCommands.scala | 21 +++- .../eclair/plugins/peerswap/SwapHelpers.scala | 34 ++++-- .../eclair/plugins/peerswap/SwapMaker.scala | 74 ++++++------ .../eclair/plugins/peerswap/SwapTaker.scala | 94 +++++++-------- .../transactions/SwapTransactions.scala | 3 +- .../plugins/peerswap/SwapInReceiverSpec.scala | 109 +++++++----------- .../plugins/peerswap/SwapInSenderSpec.scala | 7 +- .../peerswap/SwapOutReceiverSpec.scala | 18 +-- .../plugins/peerswap/SwapOutSenderSpec.scala | 31 +---- .../plugins/peerswap/SwapRegisterSpec.scala | 25 +--- 10 files changed, 182 insertions(+), 234 deletions(-) diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala index 8d5859235f..15f8501166 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala @@ -17,11 +17,12 @@ package fr.acinq.eclair.plugins.peerswap import akka.actor.typed.ActorRef -import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} import fr.acinq.eclair.ShortChannelId import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchTxConfirmedTriggered} -import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} +import fr.acinq.eclair.db.OutgoingPaymentStatus.Failed +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentFailed, PaymentReceived} import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status} import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInRequest, SwapOutRequest} @@ -53,8 +54,18 @@ object SwapCommands { sealed trait AwaitClaimPaymentMessages extends SwapCommand case class CsvDelayConfirmed(csvDelayTriggered: WatchFundingDeeplyBuriedTriggered) extends SwapCommand with WaitCsvMessages - case class PaymentEventReceived(paymentEvent: PaymentEvent) extends AwaitClaimPaymentMessages with PayClaimInvoiceMessages with AwaitFeePaymentMessages with PayFeeInvoiceMessages + sealed trait PayClaimPaymentMessages extends SwapCommand + + sealed trait WrappedPaymentEvent extends PayFeeInvoiceMessages with PayClaimPaymentMessages { + def paymentHash: ByteVector32 + } + case class WrappedPaymentReceived(paymentEvent: PaymentReceived) extends AwaitFeePaymentMessages with AwaitClaimPaymentMessages { + def paymentHash: ByteVector32 = paymentEvent.paymentHash + } + case class WrappedPaymentSent(paymentHash: ByteVector32, paymentPreimage: ByteVector32) extends WrappedPaymentEvent + case class WrappedPaymentFailed(paymentHash: ByteVector32, failure: Either[PaymentFailed, Failed]) extends WrappedPaymentEvent + case class WrappedPaymentPending(paymentHash: ByteVector32) extends WrappedPaymentEvent sealed trait ClaimSwapCoopMessages extends SwapCommand case object ClaimTxCommitted extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages case object ClaimTxFailed extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages @@ -72,13 +83,11 @@ object SwapCommands { sealed trait SendAgreementMessages extends SwapCommand sealed trait AwaitFeePaymentMessages extends SwapCommand - sealed trait PayClaimInvoiceMessages extends SwapCommand - sealed trait ClaimSwapMessages extends SwapCommand sealed trait PayFeeInvoiceMessages extends SwapCommand - sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages + sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with PayClaimPaymentMessages with ClaimSwapMessages 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/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 5363eeb96b..19fa9fcd43 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 @@ -18,7 +18,6 @@ package fr.acinq.eclair.plugins.peerswap 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.ScriptFlags @@ -30,10 +29,11 @@ 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.db.OutgoingPaymentStatus.{Failed, Pending, Succeeded} import fr.acinq.eclair.db.PaymentType import fr.acinq.eclair.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode -import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentFailed, PaymentReceived, PaymentSent} import fr.acinq.eclair.plugins.peerswap.SwapCommands._ import fr.acinq.eclair.plugins.peerswap.SwapEvents.TransactionPublished import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{SwapTransactionWithInputInfo, makeSwapOpeningTxOut} @@ -68,14 +68,30 @@ private object SwapHelpers { def watchForTxCsvConfirmation(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchFundingDeeplyBuriedTriggered], txId: ByteVector32, minDepth: Long): Unit = watcher ! WatchFundingDeeplyBuried(replyTo, txId, minDepth) - 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 payInvoice(nodeParams: NodeParams)(paymentInitiator: actor.ActorRef, swapId: String, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand]): Unit = + context.self ! nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).collectFirst { + // handle a payment that has already been sent; do not pay twice + case p if p.status.isInstanceOf[Succeeded] => WrappedPaymentSent(invoice.paymentHash, p.status.asInstanceOf[Succeeded].paymentPreimage) + case p if p.status.isInstanceOf[Failed] => WrappedPaymentFailed(invoice.paymentHash, Right(p.status.asInstanceOf[Failed])) + case p if p.status == Pending => WrappedPaymentPending(invoice.paymentHash) + }.getOrElse({ + paymentInitiator ! SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) + WrappedPaymentPending(invoice.paymentHash) + }) + + def watchForPaymentSent(watch: Boolean)(implicit context: ActorContext[SwapCommand]): Unit = + if (watch) { + context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[PaymentSent](ps => WrappedPaymentSent(ps.paymentHash, ps.paymentPreimage))) + context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[PaymentFailed](pf => WrappedPaymentFailed(pf.paymentHash, Left(pf)))) + } + else { + context.system.eventStream ! EventStream.Unsubscribe(context.messageAdapter[PaymentSent](ps => WrappedPaymentSent(ps.paymentHash, ps.paymentPreimage))) + context.system.eventStream ! EventStream.Unsubscribe(context.messageAdapter[PaymentFailed](pf => WrappedPaymentFailed(pf.paymentHash, Left(pf)))) + } - private def paymentEventAdapter(context: ActorContext[SwapCommand]): ActorRef[PaymentEvent] = context.messageAdapter[PaymentEvent](PaymentEventReceived) + def watchForPaymentReceived(watch: Boolean)(implicit context: ActorContext[SwapCommand]): Unit = + if (watch) context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[PaymentReceived](WrappedPaymentReceived)) + else context.system.eventStream ! EventStream.Unsubscribe(context.messageAdapter[PaymentReceived](WrappedPaymentReceived)) def makeUnknownMessage(message: HasSwapId): UnknownMessage = { val encoded = peerSwapMessageCodecWithFallback.encode(message).require 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 0189eb5208..c56e908a7d 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 @@ -30,15 +30,15 @@ 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.db.IncomingPaymentStatus.Received +import fr.acinq.eclair.payment.Bolt11Invoice import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceiveStandardPayment} -import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} 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._ import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker -import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond} @@ -168,15 +168,15 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } private def awaitFeePayment(request: SwapOutRequest, agreement: SwapOutAgreement, invoice: Bolt11Invoice): Behavior[SwapCommand] = { - watchForPayment(watch = true) // subscribe to be notified of payment events + watchForPaymentReceived(watch = true) send(switchboard, remoteNodeId)(agreement) Behaviors.withTimers { timers => timers.startSingleTimer(swapFeeExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) receiveSwapMessage[AwaitFeePaymentMessages](context, "awaitFeePayment") { - case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= invoice.amount_opt.get => + case WrappedPaymentReceived(p) if p.paymentHash == invoice.paymentHash && p.amount >= invoice.amount_opt.get => createOpeningTx(request, agreement, isInitiator = false) - case PaymentEventReceived(_) => Behaviors.same + case WrappedPaymentReceived(_) => Behaviors.same case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitFeePayment", m)) case InvoiceExpired => swapCanceled(FeePaymentInvoiceExpired(request.swapId)) @@ -206,53 +206,49 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } } - private def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = - db.find(request.swapId) match { - case Some(s: SwapData) => - awaitClaimPayment(request, agreement, s.invoice, s.openingTxBroadcasted, isInitiator) - case None => - val receivePayment = ReceiveStandardPayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left(s"swap ${request.swapId}")) - 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.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) - Behaviors.same - case OpeningTxFunded(invoice, fundingResponse) => - commitOpening(wallet)(request.swapId, invoice, fundingResponse, s"swap ${request.swapId} opening tx") - Behaviors.same - case OpeningTxCommitted(invoice, openingTxBroadcasted) => - db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator, remoteNodeId)) - awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted, isInitiator) - case OpeningTxFundingFailed(cause) => swapCanceled(OpeningFundingFailed(request.swapId, cause)) - case OpeningTxCommitFailed(r) => rollback(wallet)(r) - Behaviors.same - case RollbackSuccess(value, r) => swapCanceled(OpeningCommitFailed(request.swapId, value, r)) - case RollbackFailure(t, r) => swapCanceled(OpeningRollbackFailed(request.swapId, r, t)) - case SwapMessageReceived(_) => Behaviors.same // ignore - case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) - Behaviors.same // ignore - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) - Behaviors.same - } + private def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = { + val receivePayment = ReceiveStandardPayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left(s"swap ${request.swapId}")) + 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.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) + Behaviors.same + case OpeningTxFunded(invoice, fundingResponse) => + commitOpening(wallet)(request.swapId, invoice, fundingResponse, s"swap ${request.swapId} opening tx") + Behaviors.same + case OpeningTxCommitted(invoice, openingTxBroadcasted) => + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator, remoteNodeId)) + awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case OpeningTxFundingFailed(cause) => swapCanceled(OpeningFundingFailed(request.swapId, cause)) + case OpeningTxCommitFailed(r) => rollback(wallet)(r) + Behaviors.same + case RollbackSuccess(value, r) => swapCanceled(OpeningCommitFailed(request.swapId, value, r)) + case RollbackFailure(t, r) => swapCanceled(OpeningRollbackFailed(request.swapId, r, t)) + case SwapMessageReceived(_) => Behaviors.same // ignore + case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) + Behaviors.same // ignore + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + Behaviors.same } + } private def awaitClaimPayment(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) match { case Some(payment) if payment.status.isInstanceOf[Received] && payment.status.asInstanceOf[Received].amount >= request.amount.sat => swapCompleted(ClaimByInvoicePaid(request.swapId)) case _ => - watchForPayment(watch = true) + watchForPaymentReceived(watch = true) send(switchboard, remoteNodeId)(openingTxBroadcasted) Behaviors.withTimers { timers => timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { // 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 => + case WrappedPaymentReceived(payment) if payment.paymentHash == invoice.paymentHash && payment.amount >= request.amount.sat => swapCompleted(ClaimByInvoicePaid(request.swapId)) + case WrappedPaymentReceived(_) => Behaviors.same 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, isInitiator) @@ -272,7 +268,7 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, def claimByCoopConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) - watchForPayment(watch = false) + watchForPaymentReceived(watch = false) watchForTxConfirmation(watcher)(openingConfirmedAdapter, openingTxId, 1) // watch for opening tx to be confirmed receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { @@ -294,7 +290,7 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private 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) + watchForPaymentReceived(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") { 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 20f8974e27..048f817ca8 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 @@ -27,8 +27,7 @@ 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.db.OutgoingPaymentStatus.{Failed, Pending, Succeeded} -import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentSent} +import fr.acinq.eclair.payment.Bolt11Invoice import fr.acinq.eclair.plugins.peerswap.SwapCommands._ import fr.acinq.eclair.plugins.peerswap.SwapEvents._ import fr.acinq.eclair.plugins.peerswap.SwapHelpers._ @@ -121,15 +120,7 @@ object SwapTaker { ShortChannelId.fromCoordinates(d.request.scid) match { case Success(shortChannelId) => val swap = new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) - // handle a payment that has already succeeded, failed or is still pending - nodeParams.db.payments.listOutgoingPayments(d.invoice.paymentHash).collectFirst { - case p if p.status.isInstanceOf[Succeeded] => swap.claimSwap(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, p.status.asInstanceOf[Succeeded].paymentPreimage, d.isInitiator) - case p if p.status.isInstanceOf[Failed] => swap.sendCoopClose(LightningPaymentFailed(d.request.swapId, Right(p.status.asInstanceOf[Failed]), "swap")) - case p if p.status == Pending => swap.payClaimInvoice(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, d.isInitiator) - }.getOrElse( - // if payment was not yet sent, fail the swap - swap.sendCoopClose(SwapPaymentNotSent(d.request.swapId)) - ) + swap.payClaimInvoice(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, d.isInitiator) case Failure(e) => context.log.error(s"Could not restore from a checkpoint with an invalid shortChannelId: $d, $e") db.addResult(CouldNotRestore(d.swapId, d)) Behaviors.stopped @@ -192,19 +183,22 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } private def payFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement, feeInvoice: Bolt11Invoice): Behavior[SwapCommand] = { - watchForPayment(watch = true) // subscribe to payment event notifications + watchForPaymentSent(watch = true) payInvoice(nodeParams)(paymentInitiator, request.swapId, feeInvoice) receiveSwapMessage[PayFeeInvoiceMessages](context, "payFeeInvoice") { - // 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: PaymentEvent) => swapCanceled(LightningPaymentFailed(request.swapId, Left(p), "fee")) - case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) - case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) - case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "payFeeInvoice", m)) + case _: WrappedPaymentPending => Behaviors.same + case p: WrappedPaymentEvent if p.paymentHash != feeInvoice.paymentHash => Behaviors.same + case p: WrappedPaymentFailed => swapCanceled(LightningPaymentFailed(request.swapId, p.failure, "fee")) + case _: WrappedPaymentSent => + // 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 + watchForPaymentSent(watch = false) + Behaviors.same + case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => + awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) + case SwapMessageReceived(_) => Behaviors.same case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) - swapCanceled(UserCanceled(request.swapId)) + sendCoopClose(UserRequestedCancel(request.swapId)) case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) Behaviors.same } @@ -251,38 +245,38 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, } private def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = - db.find(request.swapId) match { - case Some(s: SwapData) => payClaimInvoice(request, agreement, openingTxBroadcasted, s.invoice, isInitiator) - case None => - Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { - case Failure(e) => sendCoopClose(SwapInvoiceInvalid(request.swapId, e)) - case Success(invoice) if invoice.amount_opt.isEmpty || invoice.amount_opt.get > request.amount.sat => - sendCoopClose(InvalidSwapInvoiceAmount(request.swapId, invoice.amount_opt, request.amount.sat)) - case Success(invoice) if invoice.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => - sendCoopClose(InvalidInvoiceChannel(request.swapId, shortChannelId, invoice.routingInfo, "swap")) - case Success(invoice) if invoice.isExpired() => - sendCoopClose(SwapPaymentInvoiceExpired(request.swapId)) - case Success(invoice) if invoice.minFinalCltvExpiryDelta >= CltvExpiryDelta(claimByCsvDelta.toInt / 2) => - sendCoopClose(InvalidSwapInvoiceExpiryDelta(request.swapId)) - case Success(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => - db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator, remoteNodeId)) - payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) - payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, isInitiator) - case Success(_) => sendCoopClose(OpeningTxInvalid(request.swapId, openingTx)) - } + Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { + case Failure(e) => sendCoopClose(SwapInvoiceInvalid(request.swapId, e)) + case Success(invoice) if invoice.amount_opt.isEmpty || invoice.amount_opt.get > request.amount.sat => + sendCoopClose(InvalidSwapInvoiceAmount(request.swapId, invoice.amount_opt, request.amount.sat)) + case Success(invoice) if invoice.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => + sendCoopClose(InvalidInvoiceChannel(request.swapId, shortChannelId, invoice.routingInfo, "swap")) + case Success(invoice) if invoice.isExpired() => + sendCoopClose(SwapPaymentInvoiceExpired(request.swapId)) + case Success(invoice) if invoice.minFinalCltvExpiryDelta >= CltvExpiryDelta(claimByCsvDelta.toInt / 2) => + sendCoopClose(InvalidSwapInvoiceExpiryDelta(request.swapId)) + case Success(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator, remoteNodeId)) + payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, isInitiator) + case Success(_) => sendCoopClose(OpeningTxInvalid(request.swapId, openingTx)) } private def payClaimInvoice(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, isInitiator: Boolean): Behavior[SwapCommand] = { - watchForPayment(watch = true) // subscribe to payment event notifications - receiveSwapMessage[PayClaimInvoiceMessages](context, "payClaimInvoice") { - case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != invoice.paymentHash => Behaviors.same - case PaymentEventReceived(p: PaymentSent) => claimSwap(request, agreement, openingTxBroadcasted, invoice, p.paymentPreimage, isInitiator) - case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(LightningPaymentFailed(request.swapId, Left(p), "swap")) - case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) - sendCoopClose(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) - Behaviors.same - } + watchForPaymentSent(watch = true) + payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) + receiveSwapMessage[PayClaimPaymentMessages](context, "payClaimInvoice") { + case _: WrappedPaymentPending => + Behaviors.same + case p: WrappedPaymentEvent if p.paymentHash != invoice.paymentHash => Behaviors.same + case p: WrappedPaymentFailed => sendCoopClose(LightningPaymentFailed(request.swapId, p.failure, "swap")) + case p: WrappedPaymentSent => + watchForPaymentSent(watch = false) + claimSwap(request, agreement, openingTxBroadcasted, invoice, p.paymentPreimage, isInitiator) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } } private def claimSwap(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, isInitiator: Boolean): Behavior[SwapCommand] = { @@ -291,7 +285,7 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, def claimByInvoiceConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) - watchForPayment(watch = false) + commitClaim(wallet)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "claim_by_invoice") receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala index 8dbeeddb3a..4eca1f98d2 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala @@ -64,7 +64,8 @@ object SwapTransactions { def validOpeningTx(openingTx: Transaction, scriptOut: Long, amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): Boolean = openingTx match { // TODO: should check that locktime is set in the past, or ideally not set at all - case Transaction(2, _, txOut, _) if txOut(scriptOut.toInt) == makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash) => true + case Transaction(2, _, txOut, _) if txOut(scriptOut.toInt).amount < amount => false + case Transaction(2, _, txOut, _) if txOut(scriptOut.toInt).publicKeyScript == makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash).publicKeyScript => true case _ => false } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala index ee9c1df507..ecb63b78ad 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -80,7 +80,14 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. val txid: String = ByteVector32.One.toHex val scriptOut: Long = 0 val blindingKey: String = "" + val paymentId: UUID = UUID.randomUUID() val request: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + val paymentFailed: PaymentFailed = PaymentFailed(paymentId, invoice.paymentHash, Seq(), 0 unixms) + val pendingPayment: OutgoingPayment = OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice.paymentHash, PaymentType.Standard, 123 msat, 123 msat, alice, 1100 unixms, Some(invoice), Pending) + val openingTxBroadcasted: OpeningTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val agreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) + def openingTx(agreement: SwapInAgreement): Transaction = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut((request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash)), 0) + def claimByInvoiceTx(agreement: SwapInAgreement): Transaction = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx(agreement).txid, openingTxBroadcasted.scriptOut.toInt) def expectSwapMessage[B](switchboard: TestProbe[Any]): B = { val unknownMessage = switchboard.expectMessageType[ForwardUnknownMessage].msg @@ -99,35 +106,40 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. val wallet = new DummyOnChainWallet() val userCli = testKit.createTestProbe[Status]() val swapEvents = testKit.createTestProbe[SwapEvent]() - val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() val nodeParams = TestConstants.Bob.nodeParams val remoteNodeId = TestConstants.Bob.nodeParams.nodeId // 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(remoteNodeId, nodeParams, paymentInitiator.ref.toClassic, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db)), "swap-in-receiver") + val swapInReceiver = testKit.spawn(SwapTaker(remoteNodeId, nodeParams, paymentInitiator.ref.toClassic, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db), "swap-in-receiver") - withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, switchboard, relayer, router, paymentInitiator, paymentHandler, nodeParams, watcher, wallet, swapEvents, remoteNodeId))) + withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, switchboard, relayer, router, paymentInitiator, paymentHandler, nodeParams, watcher, wallet, swapEvents, remoteNodeId))) } - case class FixtureParam(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) + case class FixtureParam(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("send cooperative close after a restore with no pending payment") { f => import f._ - val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) - val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) swapInReceiver ! RestoreSwap(swapData) - monitor.expectMessageType[RestoreSwap] + + // send the payment because no pending payment was in the database + paymentInitiator.expectMessageType[SendPaymentToNode] + + // the payment fails + testKit.system.eventStream ! Publish(paymentFailed) + + // send a cooperative close because the swap maker has committed the opening tx + assert(expectSwapMessage[CoopClose](switchboard).message == LightningPaymentFailed(swapId, Left(paymentFailed), "swap").toString) val deathWatcher = testKit.createTestProbe[Any]() deathWatcher.expectTerminated(swapInReceiver) // the swap result has been recorded in the db - assert(db.list().head.result == ClaimByCoopOffered(swapId, SwapPaymentNotSent(swapId).toString()).toString) + assert(db.list().head.result == ClaimByCoopOffered(swapId, LightningPaymentFailed(swapId, Left(paymentFailed), "swap").toString).toString) db.remove(swapId) } @@ -135,19 +147,21 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) // add failed outgoing payment to the payments databases - val paymentId = UUID.randomUUID() - nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice.paymentHash, PaymentType.Standard, 123 msat, 123 msat, alice, 1100 unixms, Some(invoice), Pending)) - nodeParams.db.payments.updateOutgoingPayment(PaymentFailed(paymentId, invoice.paymentHash, Seq(), 0 unixms)) + nodeParams.db.payments.addOutgoingPayment(pendingPayment) + nodeParams.db.payments.updateOutgoingPayment(paymentFailed) assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) swapInReceiver ! RestoreSwap(swapData) - monitor.expectMessageType[RestoreSwap] + + // do not send a payment because a pending payment was in the database + paymentInitiator.expectNoMessage(100 millis) + + // send a cooperative close because the swap maker has committed the opening tx + assert(expectSwapMessage[CoopClose](switchboard).message == LightningPaymentFailed(swapId, Right(Failed(Seq(), paymentFailed.timestamp)), "swap").toString) val deathWatcher = testKit.createTestProbe[Any]() deathWatcher.expectTerminated(swapInReceiver) @@ -161,8 +175,6 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) @@ -173,24 +185,21 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) swapInReceiver ! RestoreSwap(swapData) - monitor.expectMessageType[RestoreSwap] // SwapInReceiver reports status of awaiting claim-by-invoice transaction swapInReceiver ! GetStatus(userCli.ref) - monitor.expectMessageType[GetStatus] - assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwap") + assert(userCli.expectMessageType[SwapStatus].behavior == "payClaimInvoice") // SwapInReceiver reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] - val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTxBroadcasted.txid, openingTxBroadcasted.scriptOut.toInt) - swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx(agreement))) swapEvents.expectMessageType[ClaimByInvoiceConfirmed] val deathWatcher = testKit.createTestProbe[Any]() deathWatcher.expectTerminated(swapInReceiver) // the swap result has been recorded in the db - assert(db.list().head.result.contains("Claimed by paid invoice:")) + assert(db.list().head.result == ClaimByInvoiceConfirmed(swapId, WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx(agreement))).toString) db.remove(swapId) } @@ -198,8 +207,6 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. 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 = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) db.add(swapData) @@ -207,28 +214,19 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(UUID.randomUUID(), UUID.randomUUID(), Some("1"), invoice.paymentHash, PaymentType.Standard, 123 msat, 123 msat, alice, 1100 unixms, Some(invoice), OutgoingPaymentStatus.Pending)) assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) + // restore with pending payment found in the database swapInReceiver ! RestoreSwap(swapData) - monitor.expectMessageType[RestoreSwap] // SwapInReceiver reports status of awaiting opening transaction swapInReceiver ! GetStatus(userCli.ref) - monitor.expectMessageType[GetStatus] assert(userCli.expectMessageType[SwapStatus].behavior == "payClaimInvoice") // pending payment successfully sent by SwapInReceiver 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) - - // SwapInReceiver reports status of awaiting claim-by-invoice transaction - swapInReceiver ! GetStatus(userCli.ref) - monitor.expectMessageType[GetStatus] - assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwap") // SwapInReceiver reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] - val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTxBroadcasted.txid, openingTxBroadcasted.scriptOut.toInt) - swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx(agreement))) swapEvents.expectMessageType[ClaimByInvoiceConfirmed] val deathWatcher = testKit.createTestProbe[Any]() @@ -244,48 +242,30 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // start new SwapInReceiver swapInReceiver ! StartSwapInReceiver(request) - monitor.expectMessage(StartSwapInReceiver(request)) // SwapInReceiver:SwapInAgreement -> SwapInSender val agreement = expectSwapMessage[SwapInAgreement](switchboard) // 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 ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx(agreement))) // SwapInReceiver validates invoice and opening transaction before paying the invoice - 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() + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams)) - // SwapInReceiver ignores payments that do not correspond to the invoice from SwapInSender + // SwapInReceiver ignores payment events 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 ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapStatus].behavior == "payClaimInvoice") // 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[SwapStatus].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.txid, openingTxBroadcasted.scriptOut.toInt) - swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) - monitor.expectMessageType[ClaimTxConfirmed] + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx(agreement))) swapEvents.expectMessageType[ClaimByInvoiceConfirmed] val deathWatcher = testKit.createTestProbe[Any]() @@ -301,21 +281,16 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // start new SwapInReceiver swapInReceiver ! StartSwapInReceiver(request) - monitor.expectMessage(StartSwapInReceiver(request)) // SwapInReceiver:SwapInAgreement -> SwapInSender - val agreement = expectSwapMessage[SwapInAgreement](switchboard) + expectSwapMessage[SwapInAgreement](switchboard) // Maker:OpeningTxBroadcasted -> Taker, with payreq invoice where minFinalCltvExpiry >= claimByCsvDelta val badInvoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapInReceiver invoice - bad min-final-cltv-expiry-delta"), claimByCsvDelta) - val openingTxBroadcasted = OpeningTxBroadcasted(swapId, badInvoice.toString, txid, scriptOut, blindingKey) - swapInReceiver ! SwapMessageReceived(openingTxBroadcasted) - monitor.expectMessageType[SwapMessageReceived] + swapInReceiver ! SwapMessageReceived(openingTxBroadcasted.copy(payreq = badInvoice.toString)) // 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 ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx(agreement))) // SwapInReceiver validates fails before paying the invoice paymentInitiator.expectNoMessage() diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala index 1d95979add..b8598b1352 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -89,18 +89,17 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo } val userCli = testKit.createTestProbe[Status]() val swapEvents = testKit.createTestProbe[SwapEvent]() - val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() val remoteNodeId = TestConstants.Bob.nodeParams.nodeId // 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(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db)), "swap-in-sender") + val swapInSender = testKit.spawn(SwapMaker(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db), "swap-in-sender") - withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, switchboard, paymentInitiator, watcher, wallet, swapEvents, remoteNodeId))) + withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, switchboard, paymentInitiator, watcher, wallet, swapEvents, remoteNodeId))) } - case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], paymentInitiator: TestProbe[Any], watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) + case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], switchboard: TestProbe[Any], paymentInitiator: TestProbe[Any], watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("happy path from restored swap") { f => import f._ diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala index 03de2dcfe9..ea48bdacc0 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -19,7 +19,6 @@ package fr.acinq.eclair.plugins.peerswap 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 @@ -29,7 +28,6 @@ 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.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} import fr.acinq.eclair.plugins.peerswap.SwapCommands._ @@ -37,10 +35,10 @@ import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoicePaid, SwapEven 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, peerSwapMessageCodec, swapOutAgreementCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec 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.{LightningMessageCodecs, UnknownMessage} +import fr.acinq.eclair.wire.protocol.LightningMessageCodecs import fr.acinq.eclair.{NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} import grizzled.slf4j.Logging import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -94,26 +92,24 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory val wallet = new DummyOnChainWallet() val userCli = testKit.createTestProbe[Status]() val swapEvents = testKit.createTestProbe[SwapEvent]() - val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() val remoteNodeId = TestConstants.Bob.nodeParams.nodeId val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) // 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 swapOutReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db)), "swap-out-receiver") + val swapOutReceiver = testKit.spawn(SwapMaker(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db), "swap-out-receiver") - withFixture(test.toNoArgTest(FixtureParam(swapOutReceiver, userCli, monitor, switchboard, relayer, router, paymentInitiator, paymentHandler, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents, remoteNodeId))) + withFixture(test.toNoArgTest(FixtureParam(swapOutReceiver, userCli, switchboard, relayer, router, paymentInitiator, paymentHandler, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents, remoteNodeId))) } - case class FixtureParam(swapOutReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) + case class FixtureParam(swapOutReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("happy path for new swap out receiver") { f => import f._ // start new SwapOutReceiver swapOutReceiver ! StartSwapOutReceiver(request) - monitor.expectMessage(StartSwapOutReceiver(request)) // SwapOutReceiver:SwapOutAgreement -> SwapOutSender val agreement = expectSwapMessage[SwapOutAgreement](switchboard) @@ -122,7 +118,6 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory // SwapOutSender 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) // SwapOutReceiver publishes opening tx on-chain @@ -133,9 +128,6 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory val openingTxBroadcasted = expectSwapMessage[OpeningTxBroadcasted](switchboard) val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get - // wait for SwapOutReceiver to subscribe to PaymentEventReceived messages - swapEvents.expectNoMessage() - // SwapOutReceiver reports status of awaiting payment swapOutReceiver ! GetStatus(userCli.ref) assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala index 01a37a6524..98bc18a5c7 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala @@ -97,26 +97,24 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l val wallet = new DummyOnChainWallet() val userCli = testKit.createTestProbe[Status]() val swapEvents = testKit.createTestProbe[SwapEvent]() - val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) val remoteNodeId: PublicKey = TestConstants.Bob.nodeParams.nodeId // 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 swapOutSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(remoteNodeId, TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db)), "swap-out-sender") + val swapOutSender = testKit.spawn(SwapTaker(remoteNodeId, TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db), "swap-out-sender") - withFixture(test.toNoArgTest(FixtureParam(swapOutSender, userCli, monitor, switchboard, relayer, router, paymentInitiator, paymentHandler, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents, remoteNodeId))) + withFixture(test.toNoArgTest(FixtureParam(swapOutSender, userCli, switchboard, relayer, router, paymentInitiator, paymentHandler, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents, remoteNodeId))) } - case class FixtureParam(swapOutSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) + case class FixtureParam(swapOutSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], switchboard: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], paymentHandler: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent], remoteNodeId: PublicKey) test("happy path for new swap out sender") { f => import f._ // start new SwapOutSender swapOutSender ! StartSwapOutSender(amount, swapId, shortChannelId) - monitor.expectMessageType[StartSwapOutSender] // SwapOutSender: SwapOutRequest -> SwapOutReceiver val request = expectSwapMessage[SwapOutRequest](switchboard) @@ -124,59 +122,40 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l // SwapOutReceiver: SwapOutAgreement -> SwapOutSender (request fee) swapOutSender ! SwapMessageReceived(SwapOutAgreement(request.protocolVersion, request.swapId, makerPubkey.toString(), feeInvoice.toString)) - monitor.expectMessageType[SwapMessageReceived] // 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)) + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(feeInvoice.amount_opt.get, feeInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams)) swapOutSender ! GetStatus(userCli.ref) - monitor.expectMessageType[GetStatus] assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") - // wait for SwapOutSender to subscribe to PaymentEventReceived messages - swapEvents.expectNoMessage() - // 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) // SwapOutSender reports status of awaiting opening transaction after paying claim invoice swapOutSender ! GetStatus(userCli.ref) - monitor.expectMessageType[GetStatus] assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) swapOutSender ! SwapMessageReceived(openingTxBroadcasted) - monitor.expectMessageType[SwapMessageReceived] // ZmqWatcher -> SwapOutSender, trigger confirmation of opening transaction val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(request.amount.sat, makerPubkey, takerPubkey, paymentInvoice.paymentHash)), 0) swapOutSender ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) - monitor.expectMessageType[OpeningTxConfirmed] // SwapOutSender validates invoice and opening transaction before paying the invoice - assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(paymentInvoice.amount_opt.get, paymentInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) - - // wait for SwapOutSender to subscribe to PaymentEventReceived messages - swapEvents.expectNoMessage() + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(paymentInvoice.amount_opt.get, paymentInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams)) // 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() // 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) // SwapOutSender reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] 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] val deathWatcher = testKit.createTestProbe[Any]() 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 896d38884e..985eb1e962 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 @@ -123,7 +123,6 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val swapEvents = testKit.createTestProbe[SwapEvent]() val register = testKit.createTestProbe[Any]() val switchboard = 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)) @@ -134,10 +133,10 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app // 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, switchboard, peer))) + withFixture(test.toNoArgTest(FixtureParam(userCli, swapEvents, register, paymentHandler, wallet, watcher, switchboard, peer))) } - case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], monitor: TestProbe[SwapRegister.Command], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[Any], switchboard: TestProbe[Any], peer: TestProbe[Any]) + case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[Any], switchboard: TestProbe[Any], peer: TestProbe[Any]) test("restore the swap register from the database") { f => import f._ @@ -151,7 +150,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice(1).paymentHash, PaymentType.Standard, 123 msat, 123 msat, aliceNodeId, 1100 unixms, Some(invoice(0)), Pending)) assert(nodeParams.db.payments.listOutgoingPayments(invoice(1).paymentHash).nonEmpty) - val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") + val swapRegister = testKit.spawn(SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData), "SwapRegister") // wait for SwapMaker and SwapTaker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -165,9 +164,6 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val swap0Completed = swapEvents.expectMessageType[ClaimByInvoicePaid] assert(swap0Completed.swapId === swapId(0)) - // swapId0: SwapRegister receives notification that the swap Maker actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(0)) - // swapId1 - wait for Taker to subscribe to PaymentEventReceived messages swapEvents.expectNoMessage() @@ -182,9 +178,6 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app swapEvents.expectMessageType[TransactionPublished] assert(swapEvents.expectMessageType[ClaimByInvoiceConfirmed].swapId === swapId(1)) - // swapId1 - SwapRegister receives notification that the swap Taker actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(1)) - testKit.stop(swapRegister) } @@ -192,15 +185,13 @@ 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, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") + val swapRegister = testKit.spawn(SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set()), "SwapRegister") swapEvents.expectNoMessage() userCli.expectNoMessage() // User:SwapInRequested -> SwapInRegister swapRegister ! SwapRequested(userCli.ref, Maker, amount, shortChannelId, None) register.expectMessageType[ForwardShortId[CMD_GET_CHANNEL_INFO]].message.replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, channelId, NORMAL, ChannelCodecsSpec.normal) - assert(monitor.expectMessageType[SwapRequested].remoteNodeId.isEmpty) - assert(monitor.expectMessageType[SwapRequested].remoteNodeId.get == remoteNodeId) val swapId = userCli.expectMessageType[SwapOpened].swapId // Alice:SwapInRequest -> Bob @@ -212,7 +203,6 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app // Bob: SwapInAgreement -> Alice swapRegister ! makePluginMessage(peer, 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)) { @@ -233,9 +223,6 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app // SwapRegister received notice that SwapInSender completed assert(swapEvents.expectMessageType[ClaimByInvoicePaid].swapId === swapId) - // SwapRegister receives notification that the swap actor stopped - assert(monitor.expectMessageType[SwapTerminated].swapId === swapId) - testKit.stop(swapRegister) } @@ -243,7 +230,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, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") + val swapRegister = testKit.spawn(SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set()), "SwapRegister") // first swap request succeeds swapRegister ! SwapRequested(userCli.ref, Maker, amount, shortChannelId, None) @@ -280,7 +267,7 @@ class SwapRegisterSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app nodeParams.db.payments.addOutgoingPayment(OutgoingPayment(paymentId, UUID.randomUUID(), Some("1"), invoice(1).paymentHash, PaymentType.Standard, 123 msat, 123 msat, aliceNodeId, 1100 unixms, Some(invoice(0)), Pending)) assert(nodeParams.db.payments.listOutgoingPayments(invoice(1).paymentHash).nonEmpty) - val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") + val swapRegister = testKit.spawn(SwapRegister(nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, switchboard.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData), "SwapRegister") val listCli = TestProbe[Iterable[Response]]() swapRegister ! ListPendingSwaps(listCli.ref) From e335323cbbdb08cdcfb8af29c0c5bc65c3a509c2 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 13 Dec 2022 16:17:48 +0100 Subject: [PATCH 28/32] cleaned up code --- .../eclair/plugins/peerswap/SwapHelpers.scala | 9 ++-- .../eclair/plugins/peerswap/SwapMaker.scala | 6 +-- .../eclair/plugins/peerswap/SwapTaker.scala | 50 +++++++++---------- 3 files changed, 33 insertions(+), 32 deletions(-) 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 19fa9fcd43..c8e0929512 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 @@ -136,12 +136,15 @@ private object SwapHelpers { context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, txInfo.tx, desc)) context.pipeToSelf(wallet.commit(txInfo.tx)) { case Success(true) => ClaimTxCommitted - case Success(false) => context.log.error(s"swap $swapId claim tx commit did not succeed with $txInfo") + case Success(false) => + context.log.error(s"swap $swapId claim tx commit did not succeed with $txInfo") ClaimTxFailed - case Failure(t) => context.log.error(s"swap $swapId claim tx commit *possibly* failed with $txInfo, exception: $t") + case Failure(t) => + context.log.error(s"swap $swapId claim tx commit *possibly* failed with $txInfo, exception: $t") ClaimTxFailed } - case Failure(e) => context.log.error(s"swap $swapId claim tx is invalid: $e") + case Failure(e) => + context.log.error(s"swap $swapId claim tx is invalid: $e") context.self ! ClaimTxInvalid } 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 c56e908a7d..47ed40539d 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 @@ -110,8 +110,7 @@ object SwapMaker { def apply(remoteNodeId: PublicKey, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommands.SwapCommand] = Behaviors.setup { context => Behaviors.receiveMessagePartial { - case StartSwapInSender(amount, swapId, shortChannelId) => - new SwapMaker(remoteNodeId, shortChannelId, nodeParams, watcher, switchboard, wallet, keyManager, db, context) + case StartSwapInSender(amount, swapId, shortChannelId) => new SwapMaker(remoteNodeId, shortChannelId, nodeParams, watcher, switchboard, wallet, keyManager, db, context) .createSwap(amount, swapId) case StartSwapOutReceiver(request: SwapOutRequest) => ShortChannelId.fromCoordinates(request.scid) match { @@ -124,7 +123,8 @@ object SwapMaker { ShortChannelId.fromCoordinates(d.request.scid) match { case Success(shortChannelId) => new SwapMaker(remoteNodeId, shortChannelId, nodeParams, watcher, switchboard, 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 from a checkpoint with an invalid shortChannelId: $d, $e") + case Failure(e) => + context.log.error(s"Could not restore from a checkpoint with an invalid shortChannelId: $d, $e") db.addResult(CouldNotRestore(d.swapId, d)) Behaviors.stopped } 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 048f817ca8..13efb097f7 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 @@ -106,22 +106,22 @@ object SwapTaker { def apply(remoteNodeId: PublicKey, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommand] = Behaviors.setup { context => Behaviors.receiveMessagePartial { - case StartSwapOutSender(amount, swapId, shortChannelId) => - new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) + case StartSwapOutSender(amount, swapId, shortChannelId) => new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) .createSwap(amount, swapId) case StartSwapInReceiver(request: SwapInRequest) => ShortChannelId.fromCoordinates(request.scid) match { case Success(shortChannelId) => new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) .validateRequest(request) - case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") + 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) => - val swap = new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) - swap.payClaimInvoice(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, d.isInitiator) - case Failure(e) => context.log.error(s"Could not restore from a checkpoint with an invalid shortChannelId: $d, $e") + case Success(shortChannelId) => new SwapTaker(remoteNodeId, shortChannelId, nodeParams, paymentInitiator, watcher, switchboard, wallet, keyManager, db, context) + .payClaimInvoice(d.request, d.agreement, d.openingTxBroadcasted, d.invoice, d.isInitiator) + case Failure(e) => + context.log.error(s"Could not restore from a checkpoint with an invalid shortChannelId: $d, $e") db.addResult(CouldNotRestore(d.swapId, d)) Behaviors.stopped } @@ -130,10 +130,8 @@ object SwapTaker { } private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { - val protocolVersion = 3 - val noAsset = "" - implicit val timeout: Timeout = 30 seconds - + private val protocolVersion = 3 + private val noAsset = "" private val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) private val premium = 0 // (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? @@ -155,16 +153,17 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private def awaitAgreement(request: SwapOutRequest): Behavior[SwapCommand] = { send(switchboard, remoteNodeId)(request) - receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { case SwapMessageReceived(agreement: SwapOutAgreement) if agreement.protocolVersion != protocolVersion => swapCanceled(WrongVersion(request.swapId, protocolVersion)) case SwapMessageReceived(agreement: SwapOutAgreement) => validateFeeInvoice(request, agreement) case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) - case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + case CancelRequested(replyTo) => + replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) + case GetStatus(replyTo) => + replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) Behaviors.same } } @@ -185,21 +184,22 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private def payFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement, feeInvoice: Bolt11Invoice): Behavior[SwapCommand] = { watchForPaymentSent(watch = true) payInvoice(nodeParams)(paymentInitiator, request.swapId, feeInvoice) - receiveSwapMessage[PayFeeInvoiceMessages](context, "payFeeInvoice") { case _: WrappedPaymentPending => Behaviors.same - case p: WrappedPaymentEvent if p.paymentHash != feeInvoice.paymentHash => Behaviors.same + case p: WrappedPaymentEvent if p.paymentHash != feeInvoice.paymentHash => + Behaviors.same case p: WrappedPaymentFailed => swapCanceled(LightningPaymentFailed(request.swapId, p.failure, "fee")) case _: WrappedPaymentSent => // 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 watchForPaymentSent(watch = false) Behaviors.same - case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => - awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) + case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) case SwapMessageReceived(_) => Behaviors.same - case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + case CancelRequested(replyTo) => + replyTo ! UserCanceled(request.swapId) sendCoopClose(UserRequestedCancel(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(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 } } @@ -215,7 +215,6 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, 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. send(switchboard, remoteNodeId)(agreement) - receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = false) case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) @@ -230,16 +229,17 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private 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(request, agreement, openingTxBroadcasted, opening.tx, isInitiator) case SwapMessageReceived(resend: OpeningTxBroadcasted) if resend == openingTxBroadcasted => Behaviors.same case SwapMessageReceived(cancel: CancelSwap) => sendCoopClose(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => sendCoopClose(InvalidMessage(request.swapId, "awaitOpeningTxConfirmed", m)) - case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + case CancelRequested(replyTo) => + replyTo ! UserCanceled(request.swapId) sendCoopClose(UserRequestedCancel(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(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 } } @@ -285,9 +285,7 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, def claimByInvoiceConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) - commitClaim(wallet)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "claim_by_invoice") - receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { case ClaimTxCommitted => Behaviors.same case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByInvoiceConfirmed(request.swapId, confirmedTriggered)) From ad28e4e89ac2f4f4c38049f447b01bdebe837673 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 13 Dec 2022 16:40:58 +0100 Subject: [PATCH 29/32] Added explanation comments for fee and premium values --- .../main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala | 2 ++ .../main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala | 2 ++ 2 files changed, 4 insertions(+) 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 47ed40539d..e2c7f52540 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 @@ -137,7 +137,9 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, val noAsset = "" implicit val timeout: Timeout = 30 seconds private implicit val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + // fee is the additional off-chain amount the Maker is asking for from the Taker to open the swap private val openingFee = (feeRatePerKw * openingTxWeight / 1000).toLong // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? + // premium is the additional on-chain amount the Taker is asking for from the Maker over the swap amount private val maxPremium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap sender calculate an acceptable premium? private def makerPrivkey(swapId: String): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey 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 13efb097f7..0bba90e6b2 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 @@ -133,7 +133,9 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private val protocolVersion = 3 private val noAsset = "" private val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + // premium is the additional on-chain amount the Taker is asking for from the Maker over the swap amount private val premium = 0 // (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat // TODO: how should swap receiver calculate an acceptable premium? + // fee is the additional off-chain amount the Maker is asking for from the Taker to open the swap 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 From 3403272cb76c79b3aa00f5739c9b07692c760123 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 14 Dec 2022 16:30:25 +0100 Subject: [PATCH 30/32] Replace verbose status messages with fewer user-friendly state messages - add verbose status information to debug log instead of returning as status --- .../plugins/peerswap/ApiSerializers.scala | 16 +--- .../plugins/peerswap/SwapCommands.scala | 6 +- .../eclair/plugins/peerswap/SwapHelpers.scala | 5 +- .../eclair/plugins/peerswap/SwapMaker.scala | 80 ++++++++++--------- .../plugins/peerswap/SwapResponses.scala | 33 ++++++-- .../eclair/plugins/peerswap/SwapTaker.scala | 30 ++++--- .../plugins/peerswap/SwapInReceiverSpec.scala | 23 +++--- .../plugins/peerswap/SwapInSenderSpec.scala | 9 +-- .../peerswap/SwapOutReceiverSpec.scala | 4 +- .../plugins/peerswap/SwapOutSenderSpec.scala | 7 +- 10 files changed, 114 insertions(+), 99 deletions(-) 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 index 1990607aa1..2e4d992d41 100644 --- 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 @@ -17,24 +17,12 @@ 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.SwapResponses.Response 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) }) @@ -52,6 +40,6 @@ object ApiSerializers { )) }) - implicit val formats: Formats = PeerSwapJsonSerializers.formats + SwapResponseSerializer + SwapStatusSerializer + SwapDataSerializer + implicit val formats: Formats = PeerSwapJsonSerializers.formats + SwapResponseSerializer + SwapDataSerializer } diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala index 15f8501166..f3575fd404 100644 --- a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala @@ -87,8 +87,8 @@ object SwapCommands { sealed trait PayFeeInvoiceMessages extends SwapCommand - sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with PayClaimPaymentMessages with ClaimSwapMessages 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 + sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with WaitCsvMessages with ClaimSwapMessages with ClaimSwapCsvMessages with ClaimSwapCoopMessages with PayFeeInvoiceMessages with SendAgreementMessages with PayClaimPaymentMessages with CreateOpeningTxMessages + case class GetStatus(replyTo: ActorRef[Status]) extends UserMessages + case class CancelRequested(replyTo: ActorRef[Response]) extends UserMessages // @Formatter:on } 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 c8e0929512..19b2f8347f 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 @@ -38,7 +38,7 @@ import fr.acinq.eclair.plugins.peerswap.SwapCommands._ import fr.acinq.eclair.plugins.peerswap.SwapEvents.TransactionPublished import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{SwapTransactionWithInputInfo, 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.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapAgreement, SwapRequest} import fr.acinq.eclair.wire.protocol.UnknownMessage import fr.acinq.eclair.{NodeParams, TimestampSecond, randomBytes32} @@ -164,4 +164,7 @@ private object SwapHelpers { nodeParams.db.payments.addIncomingPayment(invoice, paymentPreimage, PaymentType.Standard) invoice } + + def logStatus(swapId: String, actor: String, behavior: String, request: SwapRequest, agreement_opt: Option[SwapAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None)(implicit context: ActorContext[SwapCommand]): Unit = + context.log.debug(s"$actor[$behavior]: $swapId, ${request.scid}, $request, $agreement_opt, $invoice_opt, $openingTxBroadcasted_opt") } \ No newline at end of file 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 e2c7f52540..e15fc0ed31 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 @@ -21,7 +21,6 @@ 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 @@ -44,7 +43,6 @@ import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond} import scodec.bits.ByteVector -import scala.concurrent.duration.DurationInt import scala.util.{Failure, Success} object SwapMaker { @@ -133,15 +131,13 @@ object SwapMaker { } private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], switchboard: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { - val protocolVersion = 3 - val noAsset = "" - implicit val timeout: Timeout = 30 seconds + private val protocolVersion = 3 + private val noAsset = "" private implicit val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) // fee is the additional off-chain amount the Maker is asking for from the Taker to open the swap private val openingFee = (feeRatePerKw * openingTxWeight / 1000).toLong // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? // premium is the additional on-chain amount the Taker is asking for from the Maker over the swap amount private val maxPremium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap sender calculate an acceptable premium? - private def makerPrivkey(swapId: String): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey private def makerPubkey(swapId: String): PublicKey = makerPrivkey(swapId).publicKey @@ -172,7 +168,6 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private def awaitFeePayment(request: SwapOutRequest, agreement: SwapOutAgreement, invoice: Bolt11Invoice): Behavior[SwapCommand] = { watchForPaymentReceived(watch = true) send(switchboard, remoteNodeId)(agreement) - Behaviors.withTimers { timers => timers.startSingleTimer(swapFeeExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) receiveSwapMessage[AwaitFeePaymentMessages](context, "awaitFeePayment") { @@ -182,9 +177,12 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitFeePayment", m)) case InvoiceExpired => swapCanceled(FeePaymentInvoiceExpired(request.swapId)) - case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + case CancelRequested(replyTo) => + replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) + replyTo ! AwaitClaimPayment(request.swapId) Behaviors.same } } @@ -192,7 +190,6 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, private def awaitAgreement(request: SwapInRequest): Behavior[SwapCommand] = { send(switchboard, remoteNodeId)(request) - receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { case SwapMessageReceived(agreement: SwapInAgreement) if agreement.protocolVersion != protocolVersion => swapCanceled(WrongVersion(request.swapId, protocolVersion)) @@ -203,7 +200,9 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitAgreement", request) + replyTo ! AwaitClaimPayment(request.swapId) Behaviors.same } } @@ -212,7 +211,6 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, val receivePayment = ReceiveStandardPayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left(s"swap ${request.swapId}")) 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.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) Behaviors.same @@ -228,9 +226,12 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case RollbackSuccess(value, r) => swapCanceled(OpeningCommitFailed(request.swapId, value, r)) case RollbackFailure(t, r) => swapCanceled(OpeningRollbackFailed(request.swapId, r, t)) case SwapMessageReceived(_) => Behaviors.same // ignore - case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) + case CancelRequested(replyTo) => + replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same // ignore - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + replyTo ! AwaitClaimPayment(request.swapId) Behaviors.same } } @@ -242,7 +243,6 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case _ => watchForPaymentReceived(watch = true) send(switchboard, remoteNodeId)(openingTxBroadcasted) - Behaviors.withTimers { timers => timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { @@ -252,11 +252,13 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case WrappedPaymentReceived(_) => Behaviors.same case SwapMessageReceived(coopClose: CoopClose) => claimSwapCoop(request, agreement, invoice, openingTxBroadcasted, coopClose, isInitiator) case SwapMessageReceived(_) => Behaviors.same - case InvoiceExpired => - waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) - case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) + case InvoiceExpired => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => + replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitClaimPayment(request.swapId) Behaviors.same } } @@ -269,22 +271,23 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, 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) - watchForPaymentReceived(watch = false) - watchForTxConfirmation(watcher)(openingConfirmedAdapter, openingTxId, 1) // watch for opening tx to be confirmed - + watchForTxConfirmation(watcher)(openingConfirmedAdapter, openingTxId, 1) receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { - case OpeningTxConfirmed(_) => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) + case OpeningTxConfirmed(_) => + watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "claim_by_coop") Behaviors.same case ClaimTxCommitted => Behaviors.same - case ClaimTxConfirmed(confirmedTriggered) => - swapCompleted(ClaimByCoopConfirmed(request.swapId, confirmedTriggered)) + case ClaimTxConfirmed(confirmedTriggered) => 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 ! CancelAfterOpeningCommit(request.swapId) + case CancelRequested(replyTo) => + replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByCoopTxConfirmation(request.swapId) Behaviors.same } } @@ -294,13 +297,14 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, def csvDelayConfirmedAdapter: ActorRef[WatchFundingDeeplyBuriedTriggered] = context.messageAdapter[WatchFundingDeeplyBuriedTriggered](CsvDelayConfirmed) watchForPaymentReceived(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(_) => - claimSwapCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) - case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) + case CsvDelayConfirmed(_) => claimSwapCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => + replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitCsv(request.swapId) Behaviors.same } } @@ -310,18 +314,20 @@ private class SwapMaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, 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)(request.swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "claim_by_csv") - receiveSwapMessage[ClaimSwapCsvMessages](context, "claimSwapCsv") { - case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCsvConfirmedAdapter, claimByCsvTx.txid, nodeParams.channelConf.minDepthBlocks) + case ClaimTxCommitted => + watchForTxConfirmation(watcher)(claimByCsvConfirmedAdapter, claimByCsvTx.txid, nodeParams.channelConf.minDepthBlocks) Behaviors.same 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 CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) + case CancelRequested(replyTo) => + replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByCsvTxConfirmation(request.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 ec6b5b2ba1..1ca0bd85ed 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 @@ -149,16 +149,37 @@ object SwapResponses { override def toString: String = s"$desc lightning payment failed: $payment" } - case class SwapPaymentNotSent(swapId: String) extends Error { - override def toString: String = s"swap lightning payment not sent" - } - case class UserRequestedCancel(swapId: String) extends Error { override def toString: String = s"cancel requested by user" } - 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" + sealed trait SwapStatus extends Status + case class AwaitingAgreement(swapId: String) extends SwapStatus { + override def toString: String = s"awaiting agreement" + } + + case class AwaitClaimPayment(swapId: String) extends SwapStatus { + override def toString: String = s"awaiting claim payment" + } + + case class AwaitOpeningTxConfirmation(swapId: String) extends SwapStatus { + override def toString: String = s"awaiting confirmation of the opening transaction" + } + + case class AwaitCsv(swapId: String) extends SwapStatus { + override def toString: String = s"awaiting CSV expiry" + } + + case class AwaitClaimByInvoiceTxConfirmation(swapId: String) extends SwapStatus { + override def toString: String = s"waiting for claim-by-invoice transaction to confirm" + } + + case class AwaitClaimByCoopTxConfirmation(swapId: String) extends SwapStatus { + override def toString: String = s"waiting for claim-by-coop transaction to confirm" + } + + case class AwaitClaimByCsvTxConfirmation(swapId: String) extends SwapStatus { + override def toString: String = s"waiting for claim-by-csv transaction to confirm" } } 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 0bba90e6b2..96c7b02b74 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 @@ -20,7 +20,6 @@ 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, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.OnChainWallet @@ -40,7 +39,6 @@ import fr.acinq.eclair.plugins.peerswap.wire.protocol._ import fr.acinq.eclair.{CltvExpiryDelta, NodeParams, ShortChannelId} import scodec.bits.ByteVector -import scala.concurrent.duration.DurationInt import scala.util.{Failure, Success} object SwapTaker { @@ -165,7 +163,8 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, replyTo ! UserCanceled(request.swapId) swapCanceled(UserCanceled(request.swapId)) case GetStatus(replyTo) => - replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) + logStatus(request.swapId, context.self.toString, "awaitAgreement", request) + replyTo ! AwaitingAgreement(request.swapId) Behaviors.same } } @@ -201,7 +200,8 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, replyTo ! UserCanceled(request.swapId) sendCoopClose(UserRequestedCancel(request.swapId)) case GetStatus(replyTo) => - replyTo ! SwapStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) + logStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) + replyTo ! AwaitOpeningTxConfirmation(request.swapId) Behaviors.same } } @@ -223,7 +223,9 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, case SwapMessageReceived(m) => sendCoopClose(InvalidMessage(request.swapId, "sendAgreement", m)) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) sendCoopClose(UserRequestedCancel(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) + replyTo ! AwaitOpeningTxConfirmation(request.swapId) Behaviors.same } } @@ -241,7 +243,8 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, replyTo ! UserCanceled(request.swapId) sendCoopClose(UserRequestedCancel(request.swapId)) case GetStatus(replyTo) => - replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) + logStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) + replyTo ! AwaitOpeningTxConfirmation(request.swapId) Behaviors.same } } @@ -267,16 +270,18 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, watchForPaymentSent(watch = true) payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) receiveSwapMessage[PayClaimPaymentMessages](context, "payClaimInvoice") { - case _: WrappedPaymentPending => + case _: WrappedPaymentPending => Behaviors.same + case p: WrappedPaymentEvent if p.paymentHash != invoice.paymentHash => Behaviors.same - case p: WrappedPaymentEvent if p.paymentHash != invoice.paymentHash => Behaviors.same case p: WrappedPaymentFailed => sendCoopClose(LightningPaymentFailed(request.swapId, p.failure, "swap")) case p: WrappedPaymentSent => watchForPaymentSent(watch = false) claimSwap(request, agreement, openingTxBroadcasted, invoice, p.paymentPreimage, isInitiator) case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) sendCoopClose(UserCanceled(request.swapId)) - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByInvoiceTxConfirmation(request.swapId) Behaviors.same } } @@ -285,7 +290,6 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, val inputInfo = makeSwapOpeningInputInfo(openingTxBroadcasted.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, openingTxBroadcasted.txid, openingTxBroadcasted.scriptOut.toInt) def claimByInvoiceConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) - watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) commitClaim(wallet)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "claim_by_invoice") receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { @@ -295,9 +299,11 @@ private class SwapTaker(remoteNodeId: PublicKey, shortChannelId: ShortChannelId, Behaviors.same case ClaimTxFailed => Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? case ClaimTxInvalid => Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? - case CancelRequested(replyTo) => replyTo ! CancelAfterClaimCommit(request.swapId) + case CancelRequested(replyTo) => replyTo ! CancelAfterOpeningCommit(request.swapId) Behaviors.same - case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByInvoiceTxConfirmation(request.swapId) Behaviors.same } } diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala index ecb63b78ad..6ad5aa12be 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -19,7 +19,6 @@ package fr.acinq.eclair.plugins.peerswap 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 @@ -38,7 +37,7 @@ import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentFailed, PaymentSent} import fr.acinq.eclair.plugins.peerswap.SwapCommands._ import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByCoopOffered, ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} -import fr.acinq.eclair.plugins.peerswap.SwapResponses.{LightningPaymentFailed, Status, SwapPaymentNotSent, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{LightningPaymentFailed, Status} import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb import fr.acinq.eclair.plugins.peerswap.transactions.SwapScripts.claimByCsvDelta import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} @@ -157,7 +156,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. swapInReceiver ! RestoreSwap(swapData) - // do not send a payment because a pending payment was in the database + // do not send a payment because a failed payment was in the database paymentInitiator.expectNoMessage(100 millis) // send a cooperative close because the swap maker has committed the opening tx @@ -171,7 +170,7 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. db.remove(swapId) } - test("claim by invoice after a restore with the payment already marked as paid") { f => + test("claim by invoice after a restore with the payment already marked as sent") { f => import f._ // restore the SwapInReceiver actor state from a confirmed on-chain opening tx @@ -186,9 +185,8 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. swapInReceiver ! RestoreSwap(swapData) - // SwapInReceiver reports status of awaiting claim-by-invoice transaction - swapInReceiver ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapStatus].behavior == "payClaimInvoice") + // do not send a payment because a sent payment was in the database + paymentInitiator.expectNoMessage(100 millis) // SwapInReceiver reports a successful claim by invoice swapEvents.expectMessageType[TransactionPublished] @@ -217,9 +215,8 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // restore with pending payment found in the database swapInReceiver ! RestoreSwap(swapData) - // SwapInReceiver reports status of awaiting opening transaction - swapInReceiver ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapStatus].behavior == "payClaimInvoice") + // do not send a payment because a pending payment was in the database + paymentInitiator.expectNoMessage(100 millis) // pending payment successfully sent by SwapInReceiver 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)) @@ -257,8 +254,6 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // SwapInReceiver ignores payment events 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)) - swapInReceiver ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapStatus].behavior == "payClaimInvoice") // 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)) @@ -292,8 +287,8 @@ case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory. // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx(agreement))) - // SwapInReceiver validates fails before paying the invoice - paymentInitiator.expectNoMessage() + // SwapInReceiver fails before paying the invoice + paymentInitiator.expectNoMessage(100 millis) // SwapInReceiver:CoopClose -> SwapInSender val coopClose = expectSwapMessage[CoopClose](switchboard) diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala index b8598b1352..5d9a775278 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -19,7 +19,6 @@ package fr.acinq.eclair.plugins.peerswap 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 @@ -34,7 +33,7 @@ import fr.acinq.eclair.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} 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.SwapResponses.{AwaitClaimByCoopTxConfirmation, AwaitClaimByCsvTxConfirmation, AwaitClaimPayment, Status} import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec import fr.acinq.eclair.plugins.peerswap.wire.protocol.{CoopClose, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} @@ -163,7 +162,7 @@ case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.lo // SwapInSender reports status of awaiting payment swapInSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") + userCli.expectMessageType[AwaitClaimPayment] // SwapInSender receives a payment with the corresponding payment hash val paymentReceived = PaymentReceived(invoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) @@ -204,7 +203,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[SwapStatus].behavior == "claimSwapCoop") + userCli.expectMessageType[AwaitClaimByCoopTxConfirmation] // ZmqWatcher -> SwapInSender, trigger confirmation of coop close transaction swapEvents.expectMessageType[TransactionPublished] @@ -243,7 +242,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[SwapStatus].behavior == "claimSwapCsv") + userCli.expectMessageType[AwaitClaimByCsvTxConfirmation] // 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/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala index ea48bdacc0..11f6ad3e84 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -32,7 +32,7 @@ import fr.acinq.eclair.io.Switchboard.ForwardUnknownMessage import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} 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.SwapResponses.{AwaitClaimPayment, Status} 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.peerSwapMessageCodec @@ -130,7 +130,7 @@ case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory // SwapOutReceiver reports status of awaiting payment swapOutReceiver ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") + userCli.expectMessageType[AwaitClaimPayment] // SwapOutReceiver receives a payment with the corresponding payment hash // TODO: convert from ShortChannelId to ByteVector32 diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala index 98bc18a5c7..1122327d16 100644 --- a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala @@ -19,7 +19,6 @@ package fr.acinq.eclair.plugins.peerswap 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 @@ -35,7 +34,7 @@ import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} 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.SwapResponses.{AwaitOpeningTxConfirmation, Status} 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.peerSwapMessageCodec @@ -126,14 +125,12 @@ case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.l // 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)) swapOutSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") // 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)) // SwapOutSender reports status of awaiting opening transaction after paying claim invoice - swapOutSender ! GetStatus(userCli.ref) - assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") + userCli.expectMessageType[AwaitOpeningTxConfirmation] // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) From 59933dde05cce31ec6c653f98ae89be6fab2c41a Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 30 Dec 2022 11:41:14 +0100 Subject: [PATCH 31/32] Remove route blinding feature when creating invoices This fixes a test failure introduced by PR #2490 when the Maker tries to create a Bolt11 invoice and the RouteBlinding invoice feature is enabled. --- .../scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 19b2f8347f..533838d120 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 @@ -23,6 +23,7 @@ import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, Transaction} +import fr.acinq.eclair.Features.RouteBlinding import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse @@ -157,9 +158,10 @@ private object SwapHelpers { def createInvoice(nodeParams: NodeParams, amount: Satoshi, description: String)(implicit context: ActorContext[SwapCommand]): Try[Bolt11Invoice] = Try { val paymentPreimage = randomBytes32() + val invoiceFeatures = nodeParams.features.invoiceFeatures().remove(RouteBlinding) 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()) + extraHops = Nil, timestamp = TimestampSecond.now(), paymentSecret = paymentPreimage, paymentMetadata = None, features = invoiceFeatures) context.log.debug("generated invoice={} from amount={} sat, description={}", invoice.toString, amount, description) nodeParams.db.payments.addIncomingPayment(invoice, paymentPreimage, PaymentType.Standard) invoice From 32e7e2da708136152238d88c242916cb3188d268 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Fri, 30 Dec 2022 11:54:06 +0100 Subject: [PATCH 32/32] Update to add basic database and seed files information --- plugins/peerswap/README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/peerswap/README.md b/plugins/peerswap/README.md index d8f04e8bdf..0a218dba3a 100644 --- a/plugins/peerswap/README.md +++ b/plugins/peerswap/README.md @@ -30,5 +30,16 @@ eclair-node-/bin/eclair-node.sh /peerswap-plugin-> --amountSat= eclair-cli swapout --shortChannelId=> --amountSat= eclair-cli listswaps +eclair-cli swaphistory eclair-cli cancelswap --swapId= -``` \ No newline at end of file +``` + +## Persistence + +This plugin stores its data into a sqlite database named `peer-swap.sqlite`. +It uses that database to ensure swaps are correctly executed even after a restart of the node. +You can check the status of pending swap by reading directly from that database or using the command `listwaps`. + +## Seed + +The seed used to generate keys for swaps is stored in the `swap_seed.dat` file. This seed should be backed up and always kept secret. \ No newline at end of file