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/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/FailureMessage.scala index 7732398452..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 @@ -24,7 +24,7 @@ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelFlagsCodec, 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 = meteredLightningMessageCodec.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 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) 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 { diff --git a/plugins/peerswap/README.md b/plugins/peerswap/README.md new file mode 100644 index 0000000000..0a218dba3a --- /dev/null +++ b/plugins/peerswap/README.md @@ -0,0 +1,45 @@ +# 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 swaphistory +eclair-cli cancelswap --swapId= +``` + +## 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 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..2e4d992d41 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiSerializers.scala @@ -0,0 +1,45 @@ +/* + * 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 +import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers +import org.json4s.{Formats, JField, JObject, JString} + +object ApiSerializers { + + 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 + SwapDataSerializer + +} 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/PeerSwapPlugin.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala new file mode 100644 index 0000000000..756099b3b7 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala @@ -0,0 +1,117 @@ +/* + * 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.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} +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.switchboard, 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.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.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) + + 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/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/SwapCommands.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala new file mode 100644 index 0000000000..f3575fd404 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.scala @@ -0,0 +1,94 @@ +/* + * 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.{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.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} + +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 + + 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 + + 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 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 AwaitClaimPaymentMessages with AwaitFeePaymentMessages + + sealed trait AwaitClaimPaymentMessages extends SwapCommand + case class CsvDelayConfirmed(csvDelayTriggered: WatchFundingDeeplyBuriedTriggered) extends SwapCommand with WaitCsvMessages + + 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 + 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 + // @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 + + sealed trait ClaimSwapMessages extends SwapCommand + + sealed trait PayFeeInvoiceMessages extends SwapCommand + + 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/SwapData.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala new file mode 100644 index 0000000000..9c6e471810 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala @@ -0,0 +1,36 @@ +/* + * 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.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} + +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, remoteNodeId: PublicKey, 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 new file mode 100644 index 0000000000..4a044773d2 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala @@ -0,0 +1,48 @@ +/* + * 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 + +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) 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" + } + 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/SwapHelpers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala new file mode 100644 index 0000000000..533838d120 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala @@ -0,0 +1,172 @@ +/* + * 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.{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.Features.RouteBlinding +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.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, 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} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback +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} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.reflect.ClassTag +import scala.util.{Failure, Success, Try} + +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}") + 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 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)))) + } + + 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 + UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.drop(16).toByteVector) + } + + 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 = { + // 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) => 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") + 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, "")) + } + } + + 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 + 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") + 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 + } + + 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] = + 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 = invoiceFeatures) + context.log.debug("generated invoice={} from amount={} sat, description={}", invoice.toString, amount, description) + 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/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())) + } +} + 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..e15fc0ed31 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.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.adapter.TypedActorRefOps +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +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.db.IncomingPaymentStatus.Received +import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceiveStandardPayment} +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.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} +import scodec.bits.ByteVector + +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(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) + .createSwap(amount, swapId) + case StartSwapOutReceiver(request: SwapOutRequest) => + ShortChannelId.fromCoordinates(request.scid) match { + 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(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)) + Behaviors.stopped + } + } + } +} + +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]) { + 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 + + 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)) + } + + 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(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) + case Failure(exception) => swapCanceled(CreateInvoiceFailed(request.swapId, exception)) + } + } + } + + 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") { + case WrappedPaymentReceived(p) if p.paymentHash == invoice.paymentHash && p.amount >= invoice.amount_opt.get => + createOpeningTx(request, agreement, isInitiator = false) + 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)) + case CancelRequested(replyTo) => + replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) + replyTo ! AwaitClaimPayment(request.swapId) + Behaviors.same + } + } + } + + 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)) + case SwapMessageReceived(agreement: SwapInAgreement) if agreement.premium > maxPremium => + swapCanceled(PremiumRejected(request.swapId)) + case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement, isInitiator = true) + 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) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitAgreement", request) + replyTo ! AwaitClaimPayment(request.swapId) + 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) => + logStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + replyTo ! AwaitClaimPayment(request.swapId) + 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 _ => + 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 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 SwapMessageReceived(_) => Behaviors.same + case InvoiceExpired => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => + replyTo ! CancelAfterOpeningCommit(request.swapId) + Behaviors.same + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitClaimPayment(request.swapId) + Behaviors.same + } + } + } + + 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) + 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) + receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { + 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 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) => + logStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByCoopTxConfirmation(request.swapId) + Behaviors.same + } + } + + 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) + 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) + Behaviors.same + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitCsv(request.swapId) + Behaviors.same + } + } + + 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), "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 CancelRequested(replyTo) => + replyTo ! CancelAfterOpeningCommit(request.swapId) + Behaviors.same + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByCsvTxConfirmation(request.swapId) + Behaviors.same + } + } + + private def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(event) + context.log.info(s"completed swap: $event.") + db.addResult(event) + Behaviors.stopped + } + + 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 + } + +} \ 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..2446f3e2f0 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala @@ -0,0 +1,218 @@ +/* + * 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} +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.{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 { + // @formatter:off + sealed trait Command + sealed trait ReplyToMessages extends Command { + def replyTo: ActorRef[Response] + } + + 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 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, 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, switchboard: 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 spawnSwap(swapRole: SwapRole, remoteNodeId: PublicKey, scid: String) = { + swapRole match { + // 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) + } + } + + 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 + }.toMap + registering(swaps) + } + + 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 ) => + swapRequested.replyTo ! SwapExistsForChannel(swapRequested.shortChannelId.toCoordinatesString) + Behaviors.same + case SwapRequested(replyTo, role, amount, shortChannelId, None) => fillRemoteNodeId(replyTo, role, amount, shortChannelId) + Behaviors.same + 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)) + 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) => + 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 { + 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 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 { + 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 + } + 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 new file mode 100644 index 0000000000..1ca0bd85ed --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.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 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 { + + sealed trait Response { + def swapId: String + } + + sealed trait Success extends Response + + 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" + } + + case class SwapNotFound(swapId: String) extends Fail { + override def toString: String = s"swap $swapId not found." + } + + 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 Error { + 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: $reason." + } + + 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 Error { + override def toString: String = s"swap $swapId canceled due to invalid message during $behavior: $message." + } + + case class CancelAfterOpeningCommit(swapId: String) extends Error { + override def toString: String = "Can not cancel swap after opening tx is committed." + } + + case class CancelAfterClaimCommit(swapId: String) extends Error { + override def toString: String = "Can not cancel swap after claim tx is committed." + } + + 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 UserRequestedCancel(swapId: String) extends Error { + override def toString: String = s"cancel requested by user" + } + + 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 new file mode 100644 index 0000000000..96c7b02b74 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala @@ -0,0 +1,331 @@ +/* + * 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 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.payment.Bolt11Invoice +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.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} +import scodec.bits.ByteVector + +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(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) + .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") + Behaviors.stopped + } + case RestoreSwap(d) => + ShortChannelId.fromCoordinates(d.request.scid) match { + 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 + } + } + } +} + +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]) { + 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 + 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] = { + 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) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitAgreement", request) + replyTo ! AwaitingAgreement(request.swapId) + Behaviors.same + } + } + + private def validateFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement): Behavior[SwapCommand] = { + Bolt11Invoice.fromString(agreement.payreq) match { + 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(InvalidInvoiceChannel(request.swapId, shortChannelId, i.routingInfo, "fee")) + case Success(i) if i.isExpired() => + swapCanceled(FeePaymentInvoiceExpired(request.swapId)) + case Success(feeInvoice) => payFeeInvoice(request, agreement, feeInvoice) + case Failure(e) => swapCanceled(FeeInvoiceInvalid(request.swapId, e)) + } + } + + 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: 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) + sendCoopClose(UserRequestedCancel(request.swapId)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) + replyTo ! AwaitOpeningTxConfirmation(request.swapId) + Behaviors.same + } + } + + private def validateRequest(request: SwapInRequest): Behavior[SwapCommand] = { + if (request.protocolVersion != protocolVersion || request.asset != noAsset || request.network != NodeParams.chainFromHash(nodeParams.chainHash)) { + swapCanceled(IncompatibleRequest(request.swapId, 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. + 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)) + case SwapMessageReceived(m) => sendCoopClose(InvalidMessage(request.swapId, "sendAgreement", m)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(UserRequestedCancel(request.swapId)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) + replyTo ! AwaitOpeningTxConfirmation(request.swapId) + Behaviors.same + } + } + + 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) + sendCoopClose(UserRequestedCancel(request.swapId)) + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) + replyTo ! AwaitOpeningTxConfirmation(request.swapId) + Behaviors.same + } + } + + private def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = + 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] = { + 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) => + logStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByInvoiceTxConfirmation(request.swapId) + Behaviors.same + } + } + + 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) + 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 => 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 ! CancelAfterOpeningCommit(request.swapId) + Behaviors.same + case GetStatus(replyTo) => + logStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) + replyTo ! AwaitClaimByInvoiceTxConfirmation(request.swapId) + Behaviors.same + } + } + + 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)) + } + + private def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(event) + context.log.info(s"completed swap: $event.") + db.addResult(event) + Behaviors.stopped + } + + 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 + } + +} \ No newline at end of file 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..38178d3154 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala @@ -0,0 +1,60 @@ +/* + * 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() + } + + 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 new file mode 100644 index 0000000000..904576d2e9 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala @@ -0,0 +1,88 @@ +/* + * 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.PublicKey +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 scodec.bits.ByteVector + +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] + + def find(swapId: String): Option[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, swapData.remoteNodeId.toHex) + statement.setString(9, "") + } + + 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"), + 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 new file mode 100644 index 0000000000..cf21ae30cc --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala @@ -0,0 +1,109 @@ +/* + * 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, 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") + } + 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, remote_node_id, 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 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.Postgres) { + inTransaction { pg => + 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 + } + } + } + + 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 new file mode 100644 index 0000000000..6625227d1b --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala @@ -0,0 +1,94 @@ +/* + * 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, 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") + } + 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, remote_node_id, 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 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 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/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/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..4eca1f98d2 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala @@ -0,0 +1,170 @@ +/* + * 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 { + + 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 + * 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).amount < amount => false + case Transaction(2, _, txOut, _) if txOut(scriptOut.toInt).publicKeyScript == makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash).publicKeyScript => 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/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/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" +} 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/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..6ad5aa12be --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -0,0 +1,297 @@ +/* + * 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.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.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.{ClaimByCoopOffered, ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +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} +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.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} + +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 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 + 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 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 swapEvents = testKit.createTestProbe[SwapEvent]() + 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(SwapTaker(remoteNodeId, nodeParams, paymentInitiator.ref.toClassic, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db), "swap-in-receiver") + + 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], 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 swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) + db.add(swapData) + swapInReceiver ! RestoreSwap(swapData) + + // 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, LightningPaymentFailed(swapId, Left(paymentFailed), "swap").toString).toString) + db.remove(swapId) + } + + 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 swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) + db.add(swapData) + + // add failed outgoing payment to the payments databases + nodeParams.db.payments.addOutgoingPayment(pendingPayment) + nodeParams.db.payments.updateOutgoingPayment(paymentFailed) + assert(nodeParams.db.payments.listOutgoingPayments(invoice.paymentHash).nonEmpty) + + swapInReceiver ! RestoreSwap(swapData) + + // 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 + assert(expectSwapMessage[CoopClose](switchboard).message == LightningPaymentFailed(swapId, Right(Failed(Seq(), paymentFailed.timestamp)), "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, LightningPaymentFailed(swapId, Right(Failed(Seq(), 0 unixms)), "swap").toString()).toString) + db.remove(swapId) + } + + 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 + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) + 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) + + // 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] + 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 == ClaimByInvoiceConfirmed(swapId, WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx(agreement))).toString) + db.remove(swapId) + } + + 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 swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false, remoteNodeId) + 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) + + // restore with pending payment found in the database + swapInReceiver ! RestoreSwap(swapData) + + // 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)) + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + 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:")) + db.remove(swapId) + } + + test("happy path for new swap in") { f => + import f._ + + // start new SwapInReceiver + swapInReceiver ! StartSwapInReceiver(request) + + // SwapInReceiver:SwapInAgreement -> SwapInSender + val agreement = expectSwapMessage[SwapInAgreement](switchboard) + + // Maker:OpeningTxBroadcasted -> Taker + swapInReceiver ! SwapMessageReceived(openingTxBroadcasted) + + // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + 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)) + + // 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 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)) + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + 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:")) + db.remove(swapId) + } + + 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) + + // SwapInReceiver:SwapInAgreement -> SwapInSender + 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) + swapInReceiver ! SwapMessageReceived(openingTxBroadcasted.copy(payreq = badInvoice.toString)) + + // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx(agreement))) + + // SwapInReceiver fails before paying the invoice + paymentInitiator.expectNoMessage(100 millis) + + // SwapInReceiver:CoopClose -> SwapInSender + 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 new file mode 100644 index 0000000000..5d9a775278 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -0,0 +1,258 @@ +/* + * 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.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.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.{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} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +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 +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 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 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 swapEvents = testKit.createTestProbe[SwapEvent]() + 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(SwapMaker(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db), "swap-in-sender") + + withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, switchboard, paymentInitiator, watcher, wallet, swapEvents, remoteNodeId))) + } + + 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._ + + // 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, remoteNodeId) + db.add(swapData) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + expectSwapMessage[OpeningTxBroadcasted](switchboard) + + // 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")) + db.remove(swapId) + } + + test("happy path for new swap") { f => + import f._ + + // start new SwapInSender + swapInSender ! StartSwapInSender(amount, swapId, shortChannelId) + + // SwapInSender: SwapInRequest -> SwapInSender + val swapInRequest = expectSwapMessage[SwapInRequest](switchboard) + + // SwapInReceiver: SwapInAgreement -> SwapInSender + swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, takerPubkey.toString(), premium)) + + // SwapInSender publishes opening tx on-chain + swapEvents.expectMessageType[TransactionPublished].tx + + // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver + val openingTxBroadcasted = expectSwapMessage[OpeningTxBroadcasted](switchboard) + 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) + 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)))) + 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")) + db.remove(swapId) + } + + 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, remoteNodeId) + db.add(swapData) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + expectSwapMessage[OpeningTxBroadcasted](switchboard) + + // 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) + userCli.expectMessageType[AwaitClaimByCoopTxConfirmation] + + // 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")) + db.remove(swapId) + } + + 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, remoteNodeId) + db.add(swapData) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + expectSwapMessage[OpeningTxBroadcasted](switchboard) + + // 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) + 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)) + + // 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/SwapIntegrationFixture.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala new file mode 100644 index 0000000000..ddb34fcde3 --- /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.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") + 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..55ac4b87fd --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -0,0 +1,341 @@ +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.DummyOnChainWallet +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} +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, SwapRequested} +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 +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.{ByteVector, HexStringSyntax} + +import scala.concurrent.{ExecutionContext, Future} +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): (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 + } + + 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 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 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 ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) + 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 + // 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 + 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._ + + // 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) + 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 ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) + 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 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 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 ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) + 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 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 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 ! 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 + 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 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 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 ! 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) + 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) + } + + 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 ! SwapRequested(bobSwap.cli.ref.toTyped, Maker, amount, shortChannelId, None) + 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 == 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) + assert(bobSwap.cli.expectMsgType[Iterable[Status]].isEmpty) + aliceSwap.swapRegister ! ListPendingSwaps(aliceSwap.cli.ref.toTyped) + assert(aliceSwap.cli.expectMsgType[Iterable[Status]].isEmpty) + } + +} 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..11f6ad3e84 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -0,0 +1,149 @@ +/* + * 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.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.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.{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 +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 +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) + 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 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 swapEvents = testKit.createTestProbe[SwapEvent]() + 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(SwapMaker(remoteNodeId, TestConstants.Alice.nodeParams, watcher.ref, switchboard.ref.toClassic, wallet, keyManager, db), "swap-out-receiver") + + 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], 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) + + // SwapOutReceiver:SwapOutAgreement -> SwapOutSender + val agreement = expectSwapMessage[SwapOutAgreement](switchboard) + assert(agreement.pubkey == makerPubkey.toHex) + + // 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)))) + testKit.system.eventStream ! Publish(feeReceived) + + // SwapOutReceiver publishes opening tx on-chain + val openingTx = swapEvents.expectMessageType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount) + + // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender + val openingTxBroadcasted = expectSwapMessage[OpeningTxBroadcasted](switchboard) + val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get + + // SwapOutReceiver reports status of awaiting payment + swapOutReceiver ! GetStatus(userCli.ref) + userCli.expectMessageType[AwaitClaimPayment] + + // 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) + + // SwapOutReceiver reports a successful claim-by-invoice was paid for + swapEvents.expectMessageType[ClaimByInvoicePaid] + + // wait for swap actor to stop + 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 new file mode 100644 index 0000000000..1122327d16 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala @@ -0,0 +1,164 @@ +/* + * 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.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.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._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +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 +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 +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 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 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 swapEvents = testKit.createTestProbe[SwapEvent]() + 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(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, switchboard, relayer, router, paymentInitiator, paymentHandler, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents, remoteNodeId))) + } + + 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) + + // SwapOutSender: SwapOutRequest -> SwapOutReceiver + val request = expectSwapMessage[SwapOutRequest](switchboard) + assert(request.pubkey == takerPubkey.toHex) + + // SwapOutReceiver: SwapOutAgreement -> SwapOutSender (request fee) + swapOutSender ! SwapMessageReceived(SwapOutAgreement(request.protocolVersion, request.swapId, makerPubkey.toString(), feeInvoice.toString)) + + // 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) + + // 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 + userCli.expectMessageType[AwaitOpeningTxConfirmation] + + // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) + swapOutSender ! SwapMessageReceived(openingTxBroadcasted) + + // 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)) + + // 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)) + + // 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)) + + // 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)) + + // 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)) + 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:")) + } +} 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..985eb1e962 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala @@ -0,0 +1,277 @@ +/* + * 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.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.peerSwapMessageCodec +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +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 +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) + val remoteNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId + + 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(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 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, paymentHandler, wallet, watcher, switchboard, peer))) + } + + 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._ + + 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 + 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(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() + + // 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)) + + // 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)) + + testKit.stop(swapRegister) + } + + test("register a new swap in the swap register") { f => + import f._ + + // initialize 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) + val swapId = userCli.expectMessageType[SwapOpened].swapId + + // Alice:SwapInRequest -> Bob + 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(peer, SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, bobPayoutPubkey.toString(), premium)) + + // 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 = expectSwapMessage[OpeningTxBroadcasted](switchboard) + + // 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) + + testKit.stop(swapRegister) + } + + test("fail subsequent swap requests on same channel") { f => + import f._ + + // initialize 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) + 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 = expectSwapMessage[SwapInRequest](switchboard) + assert(response.swapId === request.swapId) + + // swap requests from the user with the same channel id should fail + swapRegister ! SwapRequested(userCli.ref, Maker, amount, shortChannelId, None) + userCli.expectMessageType[SwapExistsForChannel] + + swapRegister ! SwapRequested(userCli.ref, Taker, amount, shortChannelId, None) + userCli.expectMessageType[SwapExistsForChannel] + + // swap requests from a peer with the same channel id should fail + swapRegister ! makePluginMessage(peer, swapInRequest) + expectCancelSwap(peer) + + 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, 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 + 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(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) + val responses = listCli.expectMessageType[Iterable[Response]] + assert(responses.size == 2) + } +} 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..436357c5ba --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala @@ -0,0 +1,122 @@ +/* + * 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" + 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)) + 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 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, remoteNodeId) + } + + test("init database two times in a row") { + val connection = DriverManager.getConnection("jdbc:sqlite::memory:") + new SqliteSwapsDb(connection) + new SqliteSwapsDb(connection) + } + + test("add/list/find/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, 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) + val swap_5 = swapData(randomBytes32().toString(),isInitiator = false, Taker, remoteNodeId) + + 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)) + 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(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)) + 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, remoteNodeId))) + } + val res = Future.sequence(futures) + Await.result(res, 60 seconds) + } +} \ No newline at end of file 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/transactions/SwapTransactionsSpec.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala new file mode 100644 index 0000000000..30d598dc43 --- /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.SwapHelpers.checkSpendable +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ +import fr.acinq.eclair.transactions.Transactions +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 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) + } + +} 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