diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index ccae994a45..d9c3d8bfa6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -303,12 +303,12 @@ class Setup(val datadir: File, txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, watcher, bitcoinClient) channelFactory = Peer.SimpleChannelFactory(nodeParams, watcher, relayer, bitcoinClient, txPublisherFactory) + paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, PaymentInitiator.SimplePaymentFactory(nodeParams, router, register)), "payment-initiator", SupervisorStrategy.Restart)) peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory) switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, peerFactory), "switchboard", SupervisorStrategy.Resume)) clientSpawner = system.actorOf(SimpleSupervisor.props(ClientSpawner.props(nodeParams.keyPair, nodeParams.socksProxy_opt, nodeParams.peerConnectionConf, switchboard, router), "client-spawner", SupervisorStrategy.Restart)) server = system.actorOf(SimpleSupervisor.props(Server.props(nodeParams.keyPair, nodeParams.peerConnectionConf, switchboard, router, serverBindingAddress, Some(tcpBound)), "server", SupervisorStrategy.Restart)) - paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams, PaymentInitiator.SimplePaymentFactory(nodeParams, router, register)), "payment-initiator", SupervisorStrategy.Restart)) _ = for (i <- 0 until config.getInt("autoprobe-count")) yield system.actorOf(SimpleSupervisor.props(Autoprobe.props(nodeParams, router, paymentInitiator), s"payment-autoprobe-$i", SupervisorStrategy.Restart)) balanceActor = system.spawn(BalanceActor(nodeParams.db, bitcoinClient, channelsListener, nodeParams.balanceCheckInterval), name = "balance-actor") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 0bb17af97e..040b7f05b1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1641,6 +1641,11 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val case Event(WatchFundingSpentTriggered(tx), d: PersistentChannelData) if tx.txid == d.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid => log.warning(s"processing local commit spent in catch-all handler") spendLocalCurrent(d) + + // forward unknown messages that originate from loaded plugins + case Event(unknownMsg: UnknownMessage, _) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => + send(unknownMsg) + stay() } onTransition { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index d5538541d4..56acc623b3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -298,6 +298,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA replyTo_opt.foreach(_ ! MessageRelay.Sent(messageId)) stay() + // TODO: plugin actors should register to receive messages with certain tags case Event(unknownMsg: UnknownMessage, d: ConnectedData) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) => context.system.eventStream.publish(UnknownMessageReceived(self, remoteNodeId, unknownMsg, d.connectionInfo)) stay() @@ -403,6 +404,11 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA self ! Peer.OutgoingMessage(msg, peerConnection) } + def replyUnknownSwap(peerConnection: ActorRef, unknownSwapId: String): Unit = { + val msg = Warning(s"unknown swap id $unknownSwapId") + self ! Peer.OutgoingMessage(msg, peerConnection) + } + def handleOpenChannel(open: Either[protocol.OpenChannel, protocol.OpenDualFundedChannel], temporaryChannelId: ByteVector32, fundingAmount: Satoshi, channelFlags: ChannelFlags, channelType_opt: Option[ChannelType], d: ConnectedData): Unit = { validateRemoteChannelType(temporaryChannelId, channelFlags, channelType_opt, d.localFeatures, d.remoteFeatures) match { case Right(channelType) => 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..7244765119 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala @@ -29,7 +29,7 @@ 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.{SubscriptionsComplete, NodeParams} +import fr.acinq.eclair.{NodeParams, SubscriptionsComplete} /** * Ties network connections to peers. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 1deb5395ff..3618d7184c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -16,12 +16,12 @@ package fr.acinq.eclair.transactions +import fr.acinq.bitcoin.ScriptFlags +import fr.acinq.bitcoin.SigHash._ +import fr.acinq.bitcoin.SigVersion._ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, ripemd160} import fr.acinq.bitcoin.scalacompat.Script._ import fr.acinq.bitcoin.scalacompat._ -import fr.acinq.bitcoin.SigHash._ -import fr.acinq.bitcoin.SigVersion._ -import fr.acinq.bitcoin.ScriptFlags import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.transactions.CommitmentOutput._ @@ -100,7 +100,7 @@ object Transactions { case object Remote extends TxOwner } - sealed trait TransactionWithInputInfo { + trait TransactionWithInputInfo { def input: InputInfo def desc: String def tx: Transaction diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index 13995723ad..a96b83aa57 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -463,6 +463,7 @@ object LightningMessageCodecs { .typecase(264, replyChannelRangeCodec) .typecase(265, gossipTimestampFilterCodec) .typecase(513, onionMessageCodec) + // NB: blank lines to minimize merge conflicts // diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index 0891c5ae23..664f9fb0c9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.{randomBytes32, randomKey} import scodec.bits._ +import scala.collection.concurrent.TrieMap import scala.concurrent.{ExecutionContext, Future, Promise} /** @@ -35,10 +36,12 @@ class DummyOnChainWallet extends OnChainWallet { import DummyOnChainWallet._ - val funded = collection.concurrent.TrieMap.empty[ByteVector32, Transaction] + var confirmedBalance: Satoshi = 1105 sat + var unconfirmedBalance: Satoshi = 561 sat + val funded: TrieMap[ByteVector32, Transaction] = collection.concurrent.TrieMap.empty[ByteVector32, Transaction] var rolledback = Set.empty[Transaction] - override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(1105 sat, 561 sat)) + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(confirmedBalance, unconfirmedBalance)) override def getReceiveAddress(label: String)(implicit ec: ExecutionContext): Future[String] = Future.successful(dummyReceiveAddress) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala index 57cc1b0df7..f14e81f735 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManagerSpec.scala @@ -16,8 +16,6 @@ package fr.acinq.eclair.crypto.keymanager -import java.io.File -import java.nio.file.Files import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, DeterministicWallet} @@ -28,6 +26,9 @@ import fr.acinq.eclair.{NodeParams, TestConstants, TestUtils} import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ +import java.io.File +import java.nio.file.Files + class LocalChannelKeyManagerSpec extends AnyFunSuite { test("generate the same secrets from the same seed") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala index 3b33f54809..333ce9a444 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalNodeKeyManagerSpec.scala @@ -16,9 +16,6 @@ package fr.acinq.eclair.crypto.keymanager -import java.io.File -import java.nio.file.Files - import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto} @@ -27,6 +24,9 @@ import fr.acinq.eclair.{NodeParams, TestUtils} import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ +import java.io.File +import java.nio.file.Files + class LocalNodeKeyManagerSpec extends AnyFunSuite { test("generate the same node id from the same seed") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 20bab2e360..bd686c86db 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.payment.send.PaymentInitiator import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol.IPAddress import fr.acinq.eclair.{BlockHeight, MilliSatoshi, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases, TestFeeEstimator} -import org.scalatest.concurrent.{Eventually, IntegrationPatience} +import org.scalatest.concurrent.{Eventually, IntegrationPatience, PatienceConfiguration} import org.scalatest.{Assertions, EitherValues} import java.net.InetAddress @@ -84,10 +84,10 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat val relayer = system.actorOf(Relayer.props(nodeParams, router, register, paymentHandler), "relayer") val txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, watcherTyped, bitcoinClient) val channelFactory = Peer.SimpleChannelFactory(nodeParams, watcherTyped, relayer, wallet, txPublisherFactory) - val peerFactory = Switchboard.SimplePeerFactory(nodeParams, wallet, channelFactory) - val switchboard = system.actorOf(Switchboard.props(nodeParams, peerFactory), "switchboard") val paymentFactory = PaymentInitiator.SimplePaymentFactory(nodeParams, router, register) val paymentInitiator = system.actorOf(PaymentInitiator.props(nodeParams, paymentFactory), "payment-initiator") + val peerFactory = Switchboard.SimplePeerFactory(nodeParams, wallet, channelFactory) + val switchboard = system.actorOf(Switchboard.props(nodeParams, peerFactory), "switchboard") readyListener.expectMsgAllOf( SubscriptionsComplete(classOf[Router]), SubscriptionsComplete(classOf[Register]), @@ -180,7 +180,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat watch1.replyTo ! WatchFundingConfirmedTriggered(blockHeight, txIndex, fundingTx) watch2.replyTo ! WatchFundingConfirmedTriggered(blockHeight, txIndex, fundingTx) - eventually { + eventually(PatienceConfiguration.Timeout(2 seconds), PatienceConfiguration.Interval(1 second)) { assert(getChannelState(node1, channelId) == NORMAL) assert(getChannelState(node2, channelId) == NORMAL) } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala index ee56239035..9a652fbbe7 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -18,9 +18,9 @@ package fr.acinq.eclair.api import akka.actor.ActorSystem import akka.http.scaladsl.server._ -import fr.acinq.eclair.{Eclair, RouteProvider} import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.api.handlers._ +import fr.acinq.eclair.{Eclair, RouteProvider} import grizzled.slf4j.Logging trait Service extends EclairDirectives with WebSocket with Node with Channel with Fees with PathFinding with Invoice with Payment with Message with OnChain with Logging { diff --git a/plugins/peerswap/README.md b/plugins/peerswap/README.md new file mode 100644 index 0000000000..7e6fb8dfd1 --- /dev/null +++ b/plugins/peerswap/README.md @@ -0,0 +1,28 @@ +# Peerswap plugin + +This plugin allows implements the PeerSwap protocol: https://github.com/ElementsProject/peerswap-spec/blob/main/peer-protocol.md + +## Build + +To build this plugin, run the following command in this directory: + +```sh +mvn package +``` + +## Run + +To run eclair with this plugin, start eclair with the following command: + +```sh +eclair-node-/bin/eclair-node.sh /peerswap-plugin-.jar +``` + +## Commands + +```sh +eclair-cli swapin --shortChannelId=> --amountSat= +eclair-cli swapout --shortChannelId=> --amountSat= +eclair-cli listswaps +eclair-cli cancelswap --swapId= +``` \ No newline at end of file diff --git a/plugins/peerswap/pom.xml b/plugins/peerswap/pom.xml new file mode 100644 index 0000000000..b76826a23f --- /dev/null +++ b/plugins/peerswap/pom.xml @@ -0,0 +1,163 @@ + + + + + 4.0.0 + + fr.acinq.eclair + eclair_2.13 + 0.7.1-SNAPSHOT + + + peerswap-plugin_2.13 + jar + peerswap-plugin + + + + + com.googlecode.maven-download-plugin + download-maven-plugin + 1.3.0 + + + download-bitcoind + generate-test-resources + + wget + + + ${maven.test.skip} + ${bitcoind.url} + true + ${project.build.directory} + ${bitcoind.md5} + ${bitcoind.sha1} + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 + + + + + + fr.acinq.eclair.plugins.peerswap.PeerSwapPlugin + + + + + + + package + + shade + + + + + + + + + + default + + true + + + https://bitcoincore.org/bin/bitcoin-core-0.21.1/bitcoin-0.21.1-x86_64-linux-gnu.tar.gz + e283a98b5e9f0b58e625e1dde661201d + 5101e29b39c33cc8e40d5f3b46dda37991b037a0 + + + + Mac + + + mac + + + + https://bitcoincore.org/bin/bitcoin-core-0.21.1/bitcoin-0.21.1-osx64.tar.gz + dfd1f323678eede14ae2cf6afb26ff6a + 4273696f90a2648f90142438221f5d1ade16afa2 + + + + Windows + + + Windows + + + + https://bitcoincore.org/bin/bitcoin-core-0.21.1/bitcoin-0.21.1-win64.zip + 1c6f5081ea68dcec7eddb9e6cdfc508d + a782cd413fc736f05fad3831d6a9f59dde779520 + + + + + + + org.scala-lang + scala-library + ${scala.version} + provided + + + fr.acinq.eclair + eclair-core_${scala.version.short} + ${project.version} + provided + + + fr.acinq.eclair + eclair-node_${scala.version.short} + ${project.version} + provided + + + + com.typesafe.akka + akka-testkit_${scala.version.short} + ${akka.version} + test + + + com.typesafe.akka + akka-actor-testkit-typed_${scala.version.short} + ${akka.version} + test + + + fr.acinq.eclair + eclair-core_${scala.version.short} + ${project.version} + tests + test-jar + test + + + + diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala new file mode 100644 index 0000000000..c010c75f3b --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiHandlers.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.http.scaladsl.common.{NameReceptacle, NameUnmarshallerReceptacle} +import akka.http.scaladsl.server.Route +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.api.serde.FormParamExtractors._ + +object ApiHandlers { + + import fr.acinq.eclair.api.serde.JsonSupport.{marshaller, serialization} + import fr.acinq.eclair.plugins.peerswap.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 peerSwapRoutes: Route = swapIn ~ swapOut ~ listSwaps ~ cancelSwap + + 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..ca27b9d86f --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/ApiSerializers.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import fr.acinq.eclair.json.MinimalSerializer +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers +import org.json4s.{Formats, JField, JObject, JString} + +object ApiSerializers { + + object SwapStatusSerializer extends MinimalSerializer({ + case x: SwapStatus => JObject(List( + JField("swap_id", JString(x.swapId)), + JField("actor", JString(x.actor)), + JField("behavior", JString(x.behavior)), + JField("request", JString(x.request.json)), + JField("agreement", JString(x.agreement_opt.collect(a => a.json).toString)), + JField("invoice", JString(x.invoice_opt.toString)), + JField("openingTxBroadcasted", JString(x.openingTxBroadcasted_opt.collect(o => o.json).toString)) + )) + }) + + object SwapResponseSerializer extends MinimalSerializer({ + case x: Response => JString(x.toString) + }) + + implicit val formats: Formats = PeerSwapJsonSerializers.formats + SwapResponseSerializer + SwapStatusSerializer + +} diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/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..e798360e92 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/PeerSwapPlugin.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.ActorSystem +import akka.actor.typed.scaladsl.AskPattern.Askable +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, ClassicSchedulerOps} +import akka.actor.typed.{ActorRef, SupervisorStrategy} +import akka.http.scaladsl.server.Route +import akka.util.Timeout +import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.db.sqlite.SqliteUtils +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status} +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.{CustomFeaturePlugin, Feature, InitFeature, Kit, NodeFeature, NodeParams, Plugin, PluginParams, RouteProvider, Setup, ShortChannelId, randomBytes32} +import grizzled.slf4j.Logging +import scodec.bits.ByteVector + +import java.io.File +import java.nio.file.Files +import scala.concurrent.Future + +/** + * This plugin implements the PeerSwap protocol: https://github.com/ElementsProject/peerswap-spec/blob/main/peer-protocol.md + */ +object PeerSwapPlugin { + // TODO: derive this set from peerSwapMessageCodec tags + val peerSwapTags: Set[Int] = Set(42069, 42071, 42073, 42075, 42077, 42079, 42081) +} + +class PeerSwapPlugin extends Plugin with RouteProvider with Logging { + + var db: SwapsDb = _ + var swapKeyManager: LocalSwapKeyManager = _ + var pluginKit: PeerSwapKit = _ + + case object PeerSwapFeature extends Feature with InitFeature with NodeFeature { + val rfcName = "peer_swap_plugin_prototype" + val mandatory = 158 + } + + override def params: PluginParams = new CustomFeaturePlugin { + // @formatter:off + override def messageTags: Set[Int] = PeerSwapPlugin.peerSwapTags + override def feature: Feature = PeerSwapFeature + override def name: String = "PeerSwap" + // @formatter:on + } + + override def onSetup(setup: Setup): Unit = { + val chain = setup.config.getString("chain") + val chainDir = new File(setup.datadir, chain) + db = new SqliteSwapsDb(SqliteUtils.openSqliteFile(chainDir, "peer-swap.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "normal")) + + // load or generate seed + val seedPath: File = new File(setup.datadir, "swap_seed.dat") + val swapSeed: ByteVector = if (seedPath.exists()) { + logger.info(s"use seed file: ${seedPath.getCanonicalPath}") + ByteVector(Files.readAllBytes(seedPath.toPath)) + } else { + val randomSeed = randomBytes32() + Files.write(seedPath.toPath, randomSeed.toArray) + logger.info(s"create new seed file: ${seedPath.getCanonicalPath}") + randomSeed.bytes + } + swapKeyManager = new LocalSwapKeyManager(swapSeed, NodeParams.hashFromChain(chain)) + } + + override def onKit(kit: Kit): Unit = { + val data = db.restore().toSet + val swapRegister = kit.system.spawn(Behaviors.supervise(SwapRegister(kit.nodeParams, kit.paymentInitiator, kit.watcher, kit.register, kit.wallet, swapKeyManager, db, data)).onFailure(SupervisorStrategy.restart), "peerswap-plugin-swap-register") + pluginKit = PeerSwapKit(kit.nodeParams, kit.system, swapRegister) + } + + override def route(eclairDirectives: EclairDirectives): Route = ApiHandlers.registerRoutes(pluginKit, eclairDirectives) + +} + +case class PeerSwapKit(nodeParams: NodeParams, system: ActorSystem, swapRegister: ActorRef[SwapRegister.Command]) { + def swapIn(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = + swapRegister.ask(ref => SwapRegister.SwapInRequested(ref, amount, shortChannelId))(timeout, system.scheduler.toTyped) + + def swapOut(shortChannelId: ShortChannelId, amount: Satoshi)(implicit timeout: Timeout): Future[Response] = + swapRegister.ask(ref => SwapRegister.SwapOutRequested(ref, amount, shortChannelId))(timeout, system.scheduler.toTyped) + + def listSwaps()(implicit timeout: Timeout): Future[Iterable[Status]] = + swapRegister.ask(ref => SwapRegister.ListPendingSwaps(ref))(timeout, system.scheduler.toTyped) + + def cancelSwap(swapId: String)(implicit timeout: Timeout): Future[Response] = + swapRegister.ask(ref => SwapRegister.CancelSwapRequested(ref, swapId))(timeout, system.scheduler.toTyped) +} diff --git a/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..74b7de5d4e --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapCommands.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 + +import akka.actor.typed.ActorRef +import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.eclair.ShortChannelId +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchOutputSpentTriggered, WatchTxConfirmedTriggered} +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, Status} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapInRequest, SwapOutRequest} +import fr.acinq.eclair.wire.protocol.UnknownMessage + +object SwapCommands { + + sealed trait SwapCommand + + // @formatter:off + case class StartSwapInSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand + case class StartSwapOutReceiver(request: SwapOutRequest) extends SwapCommand + case class RestoreSwap(swapData: SwapData) extends SwapCommand + case object AbortSwap extends SwapCommand + + sealed trait CreateSwapMessages extends SwapCommand + case object StateTimeout extends CreateSwapMessages with AwaitAgreementMessages with CreateOpeningTxMessages with ClaimSwapCsvMessages with WaitCsvMessages with AwaitFeePaymentMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages + case class ChannelDataFailure(failure: Register.ForwardShortIdFailure[CMD_GET_CHANNEL_DATA]) extends CreateSwapMessages + case class ChannelDataResult(channelData: RES_GET_CHANNEL_DATA[ChannelData]) extends CreateSwapMessages + + sealed trait AwaitAgreementMessages extends SwapCommand + + case class SwapMessageReceived(message: HasSwapId) extends AwaitAgreementMessages with CreateOpeningTxMessages with AwaitClaimPaymentMessages with AwaitFeePaymentMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with ClaimSwapMessages with PayFeeInvoiceMessages with SendAgreementMessages + case class ForwardFailureAdapter(result: Register.ForwardFailure[UnknownMessage]) extends AwaitAgreementMessages + + sealed trait CreateOpeningTxMessages extends SwapCommand + case class InvoiceResponse(invoice: Bolt11Invoice) extends CreateOpeningTxMessages + case class OpeningTxFunded(invoice: Bolt11Invoice, fundingResponse: MakeFundingTxResponse) extends CreateOpeningTxMessages + case class OpeningTxCommitted(invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) extends CreateOpeningTxMessages + case class OpeningTxFailed(error: String, fundingResponse_opt: Option[MakeFundingTxResponse] = None) extends CreateOpeningTxMessages + case class RollbackSuccess(error: String, status: Boolean) extends CreateOpeningTxMessages + case class RollbackFailure(error: String, exception: Throwable) extends CreateOpeningTxMessages + + sealed trait AwaitOpeningTxConfirmedMessages extends SwapCommand + case class OpeningTxConfirmed(openingConfirmedTriggered: WatchTxConfirmedTriggered) extends AwaitOpeningTxConfirmedMessages with ClaimSwapCoopMessages + case object InvoiceExpired extends AwaitOpeningTxConfirmedMessages with AwaitClaimPaymentMessages with AwaitFeePaymentMessages + + sealed trait AwaitClaimPaymentMessages extends SwapCommand + case class CsvDelayConfirmed(csvDelayTriggered: WatchFundingDeeplyBuriedTriggered) extends SwapCommand with WaitCsvMessages + case class PaymentEventReceived(paymentEvent: PaymentEvent) extends AwaitClaimPaymentMessages with PayClaimInvoiceMessages with AwaitFeePaymentMessages with PayFeeInvoiceMessages + + sealed trait ClaimSwapCoopMessages extends SwapCommand + case object ClaimTxCommitted extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + case class ClaimTxFailed(error: String) extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + case class ClaimTxInvalid(exception: Throwable) extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + case class ClaimTxConfirmed(claimByCoopConfirmedTriggered: WatchTxConfirmedTriggered) extends ClaimSwapCoopMessages with ClaimSwapCsvMessages with ClaimSwapMessages + + sealed trait WaitCsvMessages extends SwapCommand + + sealed trait ClaimSwapCsvMessages extends SwapCommand + // @Formatter:on + + // @formatter:off + case class StartSwapInReceiver(request: SwapInRequest) extends SwapCommand + case class StartSwapOutSender(amount: Satoshi, swapId: String, shortChannelId: ShortChannelId) extends SwapCommand + + sealed trait SendAgreementMessages extends SwapCommand + sealed trait AwaitFeePaymentMessages extends SwapCommand + case class ForwardShortIdFailureAdapter(result: Register.ForwardShortIdFailure[UnknownMessage]) extends AwaitFeePaymentMessages with SendCoopCloseMessages with SendAgreementMessages + + sealed trait ValidateTxMessages extends SwapCommand + case class ValidInvoice(invoice: Bolt11Invoice) extends ValidateTxMessages + case class InvalidInvoice(reason: String) extends ValidateTxMessages + + sealed trait PayClaimInvoiceMessages extends SwapCommand + + sealed trait SendCoopCloseMessages extends SwapCommand + case class OpeningTxOutputSpent(openingTxOutputSpentTriggered: WatchOutputSpentTriggered) extends SendCoopCloseMessages + + sealed trait ClaimSwapMessages extends SwapCommand + + sealed trait PayFeeInvoiceMessages extends SwapCommand + + sealed trait UserMessages extends AwaitFeePaymentMessages with AwaitAgreementMessages with CreateOpeningTxMessages with AwaitOpeningTxConfirmedMessages with ValidateTxMessages with PayClaimInvoiceMessages with AwaitClaimPaymentMessages with ClaimSwapMessages with SendCoopCloseMessages with ClaimSwapCoopMessages with WaitCsvMessages with ClaimSwapCsvMessages + case class GetStatus(replyTo: ActorRef[Status]) extends UserMessages with PayFeeInvoiceMessages with SendAgreementMessages + case class CancelRequested(replyTo: ActorRef[Response]) extends UserMessages with PayFeeInvoiceMessages with SendAgreementMessages + // @Formatter:on +} diff --git a/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..a10eebf8a4 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapData.scala @@ -0,0 +1,32 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.plugins.peerswap.SwapRole.SwapRole +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapAgreement, SwapRequest} + +object SwapRole extends Enumeration { + type SwapRole = Value + val Maker: SwapRole.Value = Value(1, "Maker") + val Taker: SwapRole.Value = Value(2, "Taker") +} + +case class SwapData(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, swapRole: SwapRole, isInitiator: Boolean) + +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..0debd87535 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapEvents.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import fr.acinq.bitcoin.scalacompat.Transaction +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.payment.PaymentReceived + +object SwapEvents { + sealed trait SwapEvent { + def swapId: String + } + + case class Canceled(swapId: String, reason: String) extends SwapEvent + case class TransactionPublished(swapId: String, tx: Transaction, desc: String) extends SwapEvent + case class TransactionConfirmed(swapId: String, tx: Transaction) extends SwapEvent + case class ClaimByInvoiceConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent + case class ClaimByCoopOffered(swapId: String, reason: String) extends SwapEvent + + + case class ClaimByInvoicePaid(swapId: String, payment: PaymentReceived) extends SwapEvent + case class ClaimByCoopConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { + override def toString: String = s"swap $swapId claimed by coop: $confirmation" + } + case class ClaimByCsvConfirmed(swapId: String, confirmation: WatchTxConfirmedTriggered) extends SwapEvent { + override def toString: String = s"swap $swapId claimed by csv: $confirmation" + } + +} diff --git a/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..f2d744912f --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapHelpers.scala @@ -0,0 +1,161 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor +import akka.actor.typed.eventstream.EventStream +import akka.actor.typed.scaladsl.adapter.TypedActorRefOps +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, Transaction} +import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_DATA, ChannelData, RES_GET_CHANNEL_DATA, Register} +import fr.acinq.eclair.db.PaymentType +import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.TransactionPublished +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.makeSwapOpeningTxOut +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted} +import fr.acinq.eclair.transactions.Transactions.{TransactionWithInputInfo, checkSpendable} +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond, randomBytes32} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.reflect.ClassTag +import scala.util.{Failure, Success, Try} + +object SwapHelpers { + + def queryChannelData(register: actor.ActorRef, shortChannelId: ShortChannelId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.ForwardShortId[CMD_GET_CHANNEL_DATA](channelDataFailureAdapter(context), shortChannelId, CMD_GET_CHANNEL_DATA(channelDataResultAdapter(context).toClassic)) + + def channelDataResultAdapter(context: ActorContext[SwapCommand]): ActorRef[RES_GET_CHANNEL_DATA[ChannelData]] = + context.messageAdapter[RES_GET_CHANNEL_DATA[ChannelData]](ChannelDataResult) + + def channelDataFailureAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortIdFailure[CMD_GET_CHANNEL_DATA]] = + context.messageAdapter[Register.ForwardShortIdFailure[CMD_GET_CHANNEL_DATA]](ChannelDataFailure) + + def receiveSwapMessage[B <: SwapCommand : ClassTag](context: ActorContext[SwapCommand], stateName: String)(f: B => Behavior[SwapCommand]): Behavior[SwapCommand] = { + context.log.debug(s"$stateName: waiting for messages, context: ${context.self.toString}") + Behaviors.receiveMessage { + case m: B => context.log.debug(s"$stateName: processing message $m") + f(m) + case m => context.log.error(s"$stateName: received unhandled message $m") + Behaviors.same + } + } + + def swapInvoiceExpiredTimer(swapId: String): String = "swap-invoice-expired-timer-" + swapId + + def swapFeeExpiredTimer(swapId: String): String = "swap-fee-expired-timer-" + swapId + + def watchForTxConfirmation(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: ByteVector32, minDepth: Long): Unit = + watcher ! WatchTxConfirmed(replyTo, txId, minDepth) + + def watchForTxCsvConfirmation(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchFundingDeeplyBuriedTriggered], txId: ByteVector32, minDepth: Long): Unit = + watcher ! WatchFundingDeeplyBuried(replyTo, txId, minDepth) + + def watchForOutputSpent(watcher: ActorRef[ZmqWatcher.Command])(replyTo: ActorRef[WatchOutputSpentTriggered], txId: ByteVector32, outputIndex: Int): Unit = + watcher ! WatchOutputSpent(replyTo, txId, outputIndex, Set()) + + def payInvoice(nodeParams: NodeParams)(paymentInitiator: actor.ActorRef, swapId: String, invoice: Bolt11Invoice): Unit = + paymentInitiator ! SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true) + + def watchForPayment(watch: Boolean)(implicit context: ActorContext[SwapCommand]): Unit = + if (watch) context.system.classicSystem.eventStream.subscribe(paymentEventAdapter(context).toClassic, classOf[PaymentEvent]) + else context.system.classicSystem.eventStream.unsubscribe(paymentEventAdapter(context).toClassic, classOf[PaymentEvent]) + + def paymentEventAdapter(context: ActorContext[SwapCommand]): ActorRef[PaymentEvent] = context.messageAdapter[PaymentEvent](PaymentEventReceived) + + def makeUnknownMessage(message: HasSwapId): UnknownMessage = { + val encoded = peerSwapMessageCodecWithFallback.encode(message).require + UnknownMessage(encoded.sliceToInt(0, 16, signed = false), encoded.toByteVector) + } + + def sendShortId(register: actor.ActorRef, shortChannelId: ShortChannelId)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.ForwardShortId(forwardShortIdAdapter(context), shortChannelId, makeUnknownMessage(message)) + + def forwardShortIdAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortIdFailure[UnknownMessage]] = + context.messageAdapter[Register.ForwardShortIdFailure[UnknownMessage]](ForwardShortIdFailureAdapter) + + def send(register: actor.ActorRef, channelId: ByteVector32)(message: HasSwapId)(implicit context: ActorContext[SwapCommand]): Unit = + register ! Register.Forward(forwardAdapter(context), channelId, makeUnknownMessage(message)) + + def forwardAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardFailure[UnknownMessage]] = + context.messageAdapter[Register.ForwardFailure[UnknownMessage]](ForwardFailureAdapter) + + def fundOpening(wallet: OnChainWallet, feeRatePerKw: FeeratePerKw)(amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand]): Unit = { + // setup conditions satisfied, create the opening tx + val openingTx = makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, invoice.paymentHash) + // funding successful, commit the opening tx + context.pipeToSelf(wallet.makeFundingTx(openingTx.publicKeyScript, amount, feeRatePerKw)) { + case Success(r) => OpeningTxFunded(invoice, r) + case Failure(cause) => OpeningTxFailed(s"error while funding swap open tx: $cause") + } + } + + def commitOpening(wallet: OnChainWallet)(swapId: String, invoice: Bolt11Invoice, fundingResponse: MakeFundingTxResponse, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = { + context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, fundingResponse.fundingTx, desc)) + context.pipeToSelf(wallet.commit(fundingResponse.fundingTx)) { + case Success(true) => context.log.debug(s"opening tx ${fundingResponse.fundingTx.txid} published for swap $swapId") + OpeningTxCommitted(invoice, OpeningTxBroadcasted(swapId, invoice.toString, fundingResponse.fundingTx.txid.toHex, fundingResponse.fundingTxOutputIndex, "")) + case Success(false) => OpeningTxFailed("could not publish swap open tx", Some(fundingResponse)) + case Failure(t) => OpeningTxFailed(s"failed to commit swap open tx, exception: $t", Some(fundingResponse)) + } + } + + def commitClaim(wallet: OnChainWallet)(swapId: String, txInfo: TransactionWithInputInfo, desc: String)(implicit context: ActorContext[SwapCommand]): Unit = + checkSpendable(txInfo) match { + case Success(_) => + // publish claim tx + context.system.eventStream ! EventStream.Publish(TransactionPublished(swapId, txInfo.tx, desc)) + context.pipeToSelf(wallet.commit(txInfo.tx)) { + case Success(true) => ClaimTxCommitted + case Success(false) => context.log.error(s"swap $swapId claim tx commit did not succeed, $txInfo") + ClaimTxFailed(s"publish did not succeed $txInfo") + case Failure(t) => context.log.error(s"swap $swapId claim tx commit failed, $txInfo") + ClaimTxFailed(s"failed to commit $txInfo, exception: $t") + } + case Failure(e) => context.log.error(s"swap $swapId claim tx is invalid: $e") + context.self ! ClaimTxInvalid(e) + } + + def rollback(wallet: OnChainWallet)(error: String, tx: Transaction)(implicit context: ActorContext[SwapCommand]): Unit = + context.pipeToSelf(wallet.rollback(tx)) { + case Success(status) => RollbackSuccess(error, status) + case Failure(t) => RollbackFailure(error, t) + } + + def createInvoice(nodeParams: NodeParams, amount: Satoshi, description: String)(implicit context: ActorContext[SwapCommand]): Try[Bolt11Invoice] = + Try { + val paymentPreimage = randomBytes32() + val invoice: Bolt11Invoice = Bolt11Invoice(nodeParams.chainHash, Some(toMilliSatoshi(amount)), Crypto.sha256(paymentPreimage), nodeParams.privateKey, Left(description), + nodeParams.channelConf.minFinalExpiryDelta, fallbackAddress = None, expirySeconds = Some(nodeParams.invoiceExpiry.toSeconds), + extraHops = Nil, timestamp = TimestampSecond.now(), paymentSecret = paymentPreimage, paymentMetadata = None, features = nodeParams.features.invoiceFeatures()) + context.log.debug("generated invoice={} from amount={} sat, description={}", invoice.toString, amount, description) + nodeParams.db.payments.addIncomingPayment(invoice, paymentPreimage, PaymentType.Standard) + invoice + } +} \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/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..8b4fd7b13f --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapMaker.scala @@ -0,0 +1,355 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor +import akka.actor.typed.eventstream.EventStream.Publish +import akka.actor.typed.scaladsl.adapter.TypedActorRefOps +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import akka.util.Timeout +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingDeeplyBuriedTriggered, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceivePayment} +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapHelpers._ +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} +import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker +import fr.acinq.eclair.plugins.peerswap.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ +import fr.acinq.eclair.{NodeParams, ShortChannelId, TimestampSecond} +import scodec.bits.ByteVector + +import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Success} + +object SwapMaker { + /* + SwapMaker SwapTaker + + "Swap Out" + RESPONDER INITIATOR + | | [createSwap] + | SwapOutRequest | + |<-------------------------------| + [validateRequest] | | [awaitAgreement] + | | + | SwapOutAgreement | + |------------------------------->| + [awaitFeePayment] | | [validateFeeInvoice] + | | + | | [payFeeInvoice] + |<------------------------------>| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Swap In" + INITIATOR RESPONDER + [createSwap] | | + | SwapInRequest | + |------------------------------->| + [awaitAgreement] | | [validateRequest] + | | + | SwapInAgreement | [sendAgreement] + |<-------------------------------| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Claim With Preimage" + [awaitClaimPayment] | | [validateOpeningTx] + | | + | | [payClaimInvoice] + |<------------------------------>| + | | [claimSwap] (claim_by_invoice) + + "Refund Cooperatively" + | | + | CoopClose | [sendCoopClose] + |<-------------------------------| + (claim_by_coop) [claimSwapCoop] | | + + "Refund After Csv" + [waitCsv] | | + | | + (claim_by_csv) [claimSwapCsv] | | + + */ + + def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommands.SwapCommand] = + Behaviors.setup { context => + Behaviors.receiveMessagePartial { + case StartSwapInSender(amount, swapId, shortChannelId) => + new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, keyManager, db, context) + .createSwap(amount, swapId) + case StartSwapOutReceiver(request: SwapOutRequest) => + ShortChannelId.fromCoordinates(request.scid) match { + case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, keyManager, db, context) + .validateRequest(request) + case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") + Behaviors.stopped + } + case RestoreSwap(d) => + ShortChannelId.fromCoordinates(d.request.scid) match { + case Success(shortChannelId) => new SwapMaker(shortChannelId, nodeParams, watcher, register, wallet, keyManager, db, context) + .awaitClaimPayment(d.request, d.agreement, d.invoice, d.openingTxBroadcasted, d.isInitiator) + case Failure(e) => context.log.error(s"could not restore swap sender with invalid shortChannelId: $d, $e") + Behaviors.stopped + } + case AbortSwap => Behaviors.stopped + } + } +} + +private class SwapMaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { + val protocolVersion = 2 + val noAsset = "" + implicit val timeout: Timeout = 30 seconds + private implicit val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + private val openingFee = (feeRatePerKw * openingTxWeight / 1000).toLong // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? + private val maxPremium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong // TODO: how should swap sender calculate an acceptable premium? + + private def makerPrivkey(swapId: String): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + private def makerPubkey(swapId: String): PublicKey = makerPrivkey(swapId).publicKey + + private def takerPubkey(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): PublicKey = + PublicKey(ByteVector.fromValidHex( + if (isInitiator) { + agreement.pubkey + } else { + request.pubkey + })) + + private def createSwap(amount: Satoshi, swapId: String): Behavior[SwapCommand] = { + awaitAgreement(SwapInRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId.toString, amount.toLong, makerPubkey(swapId).toHex)) + } + + def validateRequest(request: SwapOutRequest): Behavior[SwapCommand] = { + // fail if swap out request is invalid, otherwise respond with agreement + if (request.protocolVersion != protocolVersion || request.asset != noAsset || request.network != NodeParams.chainFromHash(nodeParams.chainHash)) { + swapCanceled(InternalError(request.swapId, s"incompatible request: $request.")) + } else { + createInvoice(nodeParams, openingFee.sat, "receive-swap-out") match { + case Success(invoice) => awaitFeePayment(request, SwapOutAgreement(protocolVersion, request.swapId, makerPubkey(request.swapId).toHex, invoice.toString), invoice) + case Failure(exception) => swapCanceled(CreateFailed(request.swapId, "could not create invoice")) + } + } + } + + private def awaitFeePayment(request: SwapOutRequest, agreement: SwapOutAgreement, invoice: Bolt11Invoice): Behavior[SwapCommand] = { + watchForPayment(watch = true) // subscribe to be notified of payment events + sendShortId(register, shortChannelId)(agreement) + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapFeeExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[AwaitFeePaymentMessages](context, "sendAgreement") { + case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= invoice.amount_opt.get => + createOpeningTx(request, agreement, isInitiator = false) + case PaymentEventReceived(_) => Behaviors.same + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) + case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitFeePayment", m)) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitFeePayment")) + case InvoiceExpired => swapCanceled(InternalError(request.swapId, "fee payment invoice expired")) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitFeePayment", request, Some(agreement)) + Behaviors.same + } + } + } + + private def awaitAgreement(request: SwapInRequest): Behavior[SwapCommand] = { + sendShortId(register, shortChannelId)(request) + + receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { + case SwapMessageReceived(agreement: SwapInAgreement) if agreement.protocolVersion != protocolVersion => + swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) + case SwapMessageReceived(agreement: SwapInAgreement) if agreement.premium > maxPremium => + swapCanceled(InternalError(request.swapId, "unacceptable premium requested.")) + case SwapMessageReceived(agreement: SwapInAgreement) => createOpeningTx(request, agreement, isInitiator = true) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitAgreement")) + case ForwardFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap request to peer.")) + case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) + Behaviors.same + } + } + + def createOpeningTx(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): Behavior[SwapCommand] = { + val receivePayment = ReceivePayment(Some(toMilliSatoshi(Satoshi(request.amount))), Left("send-swap-in")) + val createInvoice = context.spawnAnonymous(CreateInvoiceActor(nodeParams)) + createInvoice ! CreateInvoiceActor.CreateInvoice(context.messageAdapter[Bolt11Invoice](InvoiceResponse).toClassic, receivePayment) + + receiveSwapMessage[CreateOpeningTxMessages](context, "createOpeningTx") { + case InvoiceResponse(invoice: Bolt11Invoice) => fundOpening(wallet, feeRatePerKw)((request.amount + agreement.premium).sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice) + Behaviors.same + case OpeningTxFunded(invoice, fundingResponse) => + commitOpening(wallet)(request.swapId, invoice, fundingResponse, "swap-in-sender-opening") + Behaviors.same + case OpeningTxCommitted(invoice, openingTxBroadcasted) => + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Maker, isInitiator)) + awaitClaimPayment(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case OpeningTxFailed(error, None) => swapCanceled(InternalError(request.swapId, s"failed to fund swap open tx, error: $error")) + case OpeningTxFailed(error, Some(r)) => rollback(wallet)(error, r.fundingTx) + Behaviors.same + case RollbackSuccess(error, value) => swapCanceled(InternalError(request.swapId, s"rollback: Success($value), error: $error")) + case RollbackFailure(error, t) => swapCanceled(InternalError(request.swapId, s"rollback exception: $t, error: $error")) + case SwapMessageReceived(_) => Behaviors.same // ignore + case StateTimeout => + // TODO: are we sure the opening transaction has not yet been committed? should we rollback locked funding outputs? + swapCanceled(InternalError(request.swapId, "timeout during CreateOpeningTx")) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same // ignore + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "createOpeningTx", request, Some(agreement)) + Behaviors.same + } + } + + def awaitClaimPayment(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { + // TODO: query payment database for received payment + watchForPayment(watch = true) // subscribe to be notified of payment events + sendShortId(register, shortChannelId)(openingTxBroadcasted) // send message to peer about opening tx broadcast + + Behaviors.withTimers { timers => + timers.startSingleTimer(swapInvoiceExpiredTimer(request.swapId), InvoiceExpired, invoice.createdAt + invoice.relativeExpiry.toSeconds - TimestampSecond.now()) + receiveSwapMessage[AwaitClaimPaymentMessages](context, "awaitClaimPayment") { + // TODO: do we need to check that all payment parts were on our given channel? eg. payment.parts.forall(p => p.fromChannelId == channelId) + case PaymentEventReceived(payment: PaymentReceived) if payment.paymentHash == invoice.paymentHash && payment.amount >= request.amount.sat => + swapCompleted(ClaimByInvoicePaid(request.swapId, payment)) + case SwapMessageReceived(coopClose: CoopClose) => claimSwapCoop(request, agreement, invoice, openingTxBroadcasted, coopClose, isInitiator) + case PaymentEventReceived(_) => Behaviors.same + case SwapMessageReceived(_) => Behaviors.same + case InvoiceExpired => + waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitClaimPayment", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + } + + def claimSwapCoop(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, coopClose: CoopClose, isInitiator: Boolean): Behavior[SwapCommand] = { + val takerPrivkey = PrivateKey(ByteVector.fromValidHex(coopClose.privkey)) + val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) + val claimByCoopTx = makeSwapClaimByCoopTx(request.amount.sat + agreement.premium.sat, makerPrivkey(request.swapId), takerPrivkey, invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(request.swapId), takerPrivkey.publicKey, invoice.paymentHash) + def claimByCoopConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) + def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) + + watchForPayment(watch = false) + watchForTxConfirmation(watcher)(openingConfirmedAdapter, openingTxId, 1) // watch for opening tx to be confirmed + + receiveSwapMessage[ClaimSwapCoopMessages](context, "claimSwapCoop") { + case OpeningTxConfirmed(_) => watchForTxConfirmation(watcher)(claimByCoopConfirmedAdapter, claimByCoopTx.txid, nodeParams.channelConf.minDepthBlocks) + commitClaim(wallet)(request.swapId, SwapClaimByCoopTx(inputInfo, claimByCoopTx), "swap-in-sender-claimbycoop") + Behaviors.same + case ClaimTxCommitted => Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => + swapCompleted(ClaimByCoopConfirmed(request.swapId, confirmedTriggered)) + case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCoop", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def waitCsv(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { + // TODO: are we sure the opening transaction has been committed? should we rollback locked funding outputs? + def csvDelayConfirmedAdapter: ActorRef[WatchFundingDeeplyBuriedTriggered] = context.messageAdapter[WatchFundingDeeplyBuriedTriggered](CsvDelayConfirmed) + watchForPayment(watch = false) + watchForTxCsvConfirmation(watcher)(csvDelayConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), claimByCsvDelta.toInt) // watch for opening tx to be buried enough that it can be claimed by csv + + receiveSwapMessage[WaitCsvMessages](context, "waitCsv") { + case CsvDelayConfirmed(_) => + claimSwapCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case StateTimeout => + // TODO: problem with the blockchain monitor? + Behaviors.same + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "waitCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def claimSwapCsv(request: SwapRequest, agreement: SwapAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { + val openingTxId = ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)) + val claimByCsvTx = makeSwapClaimByCsvTx(request.amount.sat + agreement.premium.sat, makerPrivkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice.paymentHash, feeRatePerKw, openingTxId, openingTxBroadcasted.scriptOut.toInt) + val inputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxBroadcasted.scriptOut.toInt, request.amount.sat + agreement.premium.sat, makerPubkey(request.swapId), takerPubkey(request, agreement, isInitiator), invoice.paymentHash) + def claimByCsvConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) + + commitClaim(wallet)(request.swapId, SwapClaimByCsvTx(inputInfo, claimByCsvTx), "swap-in-sender-claimByCsvTx") + + receiveSwapMessage[ClaimSwapCsvMessages](context, "claimSwapCsv") { + case ClaimTxCommitted => watchForTxConfirmation(watcher)(claimByCsvConfirmedAdapter, claimByCsvTx.txid, nodeParams.channelConf.minDepthBlocks) + Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByCsvConfirmed(request.swapId, confirmedTriggered)) + case ClaimTxFailed(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case ClaimTxInvalid(_) => waitCsv(request, agreement, invoice, openingTxBroadcasted, isInitiator) + case StateTimeout => + // TODO: handle when claim tx not confirmed, resubmit the tx? + Behaviors.same + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after opening tx committed.") + Behaviors.same + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwapCsv", request, Some(agreement), Some(invoice), Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(event) + context.log.info(s"completed swap: $event.") + db.addResult(event) + Behaviors.stopped + } + + def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + val swapEvent = Canceled(failure.swapId, failure.toString) + context.system.eventStream ! Publish(swapEvent) + if (!failure.isInstanceOf[PeerCanceled]) sendShortId(register, shortChannelId)(CancelSwap(failure.swapId, failure.toString)) + failure match { + case e: Error => context.log.error(s"canceled swap: $e") + case f: Fail => context.log.info(s"canceled swap: $f") + case _ => context.log.error(s"canceled swap $failure.swapId, reason: unknown.") + } + Behaviors.stopped + } + +} \ No newline at end of file diff --git a/plugins/peerswap/src/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..bb59b21e04 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapRegister.scala @@ -0,0 +1,175 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor +import akka.actor.typed +import akka.actor.typed.ActorRef.ActorRefOps +import akka.actor.typed.scaladsl.adapter.TypedActorRefOps +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy} +import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.io.UnknownMessageReceived +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapRegister.Command +import fr.acinq.eclair.plugins.peerswap.SwapResponses._ +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, SwapInRequest, SwapOutRequest, SwapRequest} +import fr.acinq.eclair.{NodeParams, ShortChannelId, randomBytes32} +import scodec.Attempt + +import scala.reflect.ClassTag + +object SwapRegister { + // @formatter:off + sealed trait Command + sealed trait ReplyToMessages extends Command { + def replyTo: ActorRef[Response] + } + + sealed trait SwapRequested extends ReplyToMessages { + def replyTo: ActorRef[Response] + def amount: Satoshi + def shortChannelId: ShortChannelId + } + + sealed trait RegisteringMessages extends Command + case class WrappedUnknownMessageReceived(message: UnknownMessageReceived) extends RegisteringMessages + case class SwapInRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with SwapRequested + case class SwapOutRequested(replyTo: ActorRef[Response], amount: Satoshi, shortChannelId: ShortChannelId) extends RegisteringMessages with SwapRequested + case class SwapTerminated(swapId: String) extends RegisteringMessages + case class ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) extends RegisteringMessages + case class CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) extends RegisteringMessages with ReplyToMessages + // @formatter:on + + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, data: Set[SwapData]): Behavior[Command] = Behaviors.setup { context => + new SwapRegister(context, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, data).initializing + } +} + +private class SwapRegister(context: ActorContext[Command], nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, data: Set[SwapData]) { + import SwapRegister._ + + case class SwapEntry(shortChannelId: String, swap: ActorRef[SwapCommands.SwapCommand]) + + private def myReceive[B <: Command : ClassTag](stateName: String)(f: B => Behavior[Command]): Behavior[Command] = + Behaviors.receiveMessage[Command] { + case m: B => f(m) + case m => + // m.replyTo ! Unhandled(stateName, m.getClass.getSimpleName) + context.log.error(s"received unhandled message while in state $stateName of ${m.getClass.getSimpleName}") + Behaviors.same + } + private def watchForUnknownMessage(watch: Boolean)(implicit context: ActorContext[Command]): Unit = + if (watch) context.system.classicSystem.eventStream.subscribe(unknownMessageReceivedAdapter(context).toClassic, classOf[UnknownMessageReceived]) + else context.system.classicSystem.eventStream.unsubscribe(unknownMessageReceivedAdapter(context).toClassic, classOf[UnknownMessageReceived]) + private def unknownMessageReceivedAdapter(context: ActorContext[Command]): ActorRef[UnknownMessageReceived] = { + context.messageAdapter[UnknownMessageReceived](WrappedUnknownMessageReceived) + } + + private def initializing: Behavior[Command] = { + val swaps = data.map { state => + val swap: typed.ActorRef[SwapCommands.SwapCommand] = { + state.swapRole match { + case SwapRole.Maker => context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) + .onFailure(typed.SupervisorStrategy.restart), "SwapMaker-" + state.request.scid) + case SwapRole.Taker => context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) + .onFailure(typed.SupervisorStrategy.restart), "SwapTaker-" + state.request.scid) + } + } + context.watchWith(swap, SwapTerminated(state.request.swapId)) + swap ! RestoreSwap(state) + state.request.swapId -> SwapEntry(state.request.scid, swap.unsafeUpcast) + }.toMap + registering(swaps) + } + + private def registering(swaps: Map[String, SwapEntry]): Behavior[Command] = { + watchForUnknownMessage(watch = true)(context) + myReceive[RegisteringMessages]("registering") { + case swapRequested: SwapRequested if swaps.exists( p => p._2.shortChannelId == swapRequested.shortChannelId.toCoordinatesString ) => + // ignore swap requests for channels with ongoing swaps + swapRequested.replyTo ! SwapExistsForChannel("", swapRequested.shortChannelId.toCoordinatesString) + Behaviors.same + case SwapInRequested(replyTo, amount, shortChannelId) => + val swapId = randomBytes32().toHex + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) + .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) + context.watchWith(swap, SwapTerminated(swapId)) + swap ! StartSwapInSender(amount, swapId, shortChannelId) + replyTo ! SwapOpened(swapId) + registering(swaps + (swapId -> SwapEntry(shortChannelId.toCoordinatesString, swap))) + case SwapOutRequested(replyTo, amount, shortChannelId) => + val swapId = randomBytes32().toHex + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) + .onFailure(SupervisorStrategy.restart), "Swap-" + shortChannelId.toString) + context.watchWith(swap, SwapTerminated(swapId)) + swap ! StartSwapOutSender(amount, swapId, shortChannelId) + replyTo ! SwapOpened(swapId) + registering(swaps + (swapId -> SwapEntry(shortChannelId.toCoordinatesString, swap))) + case ListPendingSwaps(replyTo: ActorRef[Iterable[Status]]) => + val aggregator = context.spawn(StatusAggregator(swaps.size, replyTo), s"status-aggregator") + swaps.values.foreach(e => e.swap ! GetStatus(aggregator)) + Behaviors.same + case CancelSwapRequested(replyTo: ActorRef[Response], swapId: String) => + swaps.get(swapId) match { + case Some(e) => e.swap ! CancelRequested(replyTo) + case None => replyTo ! SwapNotFound(swapId) + } + Behaviors.same + case SwapTerminated(swapId) => + registering(swaps - swapId) + case WrappedUnknownMessageReceived(unknownMessageReceived) => + if (PeerSwapPlugin.peerSwapTags.contains(unknownMessageReceived.message.tag)) { + peerSwapMessageCodec.decode(unknownMessageReceived.message.data.toBitVector) match { + case Attempt.Successful(decodedMessage) => decodedMessage.value match { + case swapRequest: SwapRequest if swaps.exists(s => s._2.shortChannelId == swapRequest.scid) => + // ignore swap requests for channels with active swaps + Behaviors.same + case request: SwapInRequest => + val swap = context.spawn(Behaviors.supervise(SwapTaker(nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db)) + .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) + context.watchWith(swap, SwapTerminated(request.swapId)) + swap ! StartSwapInReceiver(request) + registering(swaps + (request.swapId -> SwapEntry(request.scid, swap))) + case request: SwapOutRequest => + val swap = context.spawn(Behaviors.supervise(SwapMaker(nodeParams, watcher, register, wallet, keyManager, db)) + .onFailure(SupervisorStrategy.restart), "Swap-" + request.scid) + context.watchWith(swap, SwapTerminated(request.swapId)) + swap ! StartSwapOutReceiver(request) + registering(swaps + (request.swapId -> SwapEntry(request.scid, swap))) + case msg: HasSwapId => swaps.get(msg.swapId) match { + // handle all other swap messages + case Some(e) => e.swap ! SwapMessageReceived(msg) + Behaviors.same + case None => context.log.error(s"received unhandled swap message: $msg") + Behaviors.same + } + } + case _ => context.log.error(s"could not decode unknown message received: $unknownMessageReceived") + Behaviors.same + } + } else { + // unknown message received without a peerswap message tag + Behaviors.same + } + } + } +} diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala new file mode 100644 index 0000000000..651a1fab8f --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapResponses.scala @@ -0,0 +1,76 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{HasSwapId, OpeningTxBroadcasted, SwapAgreement, SwapRequest} + +object SwapResponses { + + sealed trait Response { + def swapId: String + } + + sealed trait Success extends Response + + case class SwapOpened(swapId: String) extends Success { + override def toString: String = s"swap $swapId opened successfully." + } + + sealed trait Fail extends Response + + sealed trait Error extends Fail + + case class SwapExistsForChannel(swapId: String, shortChannelId: String) extends Fail { + override def toString: String = s"swap $swapId already exists for channel $shortChannelId" + } + + case class SwapNotFound(swapId: String) extends Fail { + override def toString: String = s"swap $swapId not found." + } + + case class UserCanceled(swapId: String) extends Fail { + override def toString: String = s"swap $swapId canceled by user." + } + + case class PeerCanceled(swapId: String, reason: String) extends Fail { + override def toString: String = s"swap $swapId canceled by peer, reason: $reason." + } + + case class CreateFailed(swapId: String, reason: String) extends Fail { + override def toString: String = s"could not create swap $swapId: $reason." + } + + case class InvalidMessage(swapId: String, behavior: String, message: HasSwapId) extends Fail { + override def toString: String = s"swap $swapId canceled due to invalid message during $behavior: $message." + } + + case class SwapError(swapId: String, reason: String) extends Error { + override def toString: String = s"swap $swapId error: $reason." + } + + case class InternalError(swapId: String, reason: String) extends Error { + override def toString: String = s"swap $swapId internal error: $reason." + } + + sealed trait Status extends Response + + case class SwapStatus(swapId: String, actor: String, behavior: String, request: SwapRequest, agreement_opt: Option[SwapAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None) extends Status { + override def toString: String = s"$actor[$behavior]: $swapId, ${request.scid}, $request, $agreement_opt, $invoice_opt, $openingTxBroadcasted_opt" + } + +} diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapScripts.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapScripts.scala new file mode 100644 index 0000000000..102ce3868e --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapScripts.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat._ +import fr.acinq.eclair.CltvExpiryDelta +import fr.acinq.eclair.transactions.Scripts +import fr.acinq.eclair.transactions.Scripts.der +import scodec.bits.ByteVector + +/** + * Created by remyers on 06/05/2022 + */ +object SwapScripts { + val claimByCsvDelta: CltvExpiryDelta = CltvExpiryDelta(1008) + + /** + * The opening transaction output script is a P2WSH: + */ + def swapOpening(makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector, csvDelay: CltvExpiryDelta = claimByCsvDelta): Seq[ScriptElt] = { + // @formatter:off + // To you with revocation key + OP_PUSHDATA(makerPubkey) :: OP_CHECKSIG :: OP_NOTIF :: + OP_PUSHDATA(makerPubkey) :: OP_CHECKSIG :: OP_NOTIF :: + OP_SIZE :: Scripts.encodeNumber(32) :: OP_EQUALVERIFY :: OP_SHA256 :: OP_PUSHDATA(paymentHash) :: OP_EQUALVERIFY :: + OP_ENDIF :: + OP_PUSHDATA(takerPubkey) :: OP_CHECKSIG :: + OP_ELSE :: + Scripts.encodeNumber(csvDelay.toInt) :: OP_CHECKSEQUENCEVERIFY :: + OP_ENDIF :: Nil + // @formatter:on + } + + /** + * This is the desired way to finish a swap. The taker sends the funds to its address by revealing the preimage of the swap invoice. + * witness: <> <> + */ + def witnessClaimByInvoice(takerSig: ByteVector64, paymentPreimage: ByteVector32, redeemScript: ByteVector): ScriptWitness = + ScriptWitness(der(takerSig) :: paymentPreimage.bytes :: ByteVector.empty :: ByteVector.empty :: redeemScript :: Nil) + + /** + * This is the way to cooperatively finish a swap. The maker refunds to its address without waiting for the CSV. + * witness: <> + */ + def witnessClaimByCoop(takerSig: ByteVector64, makerSig: ByteVector64, redeemScript: ByteVector): ScriptWitness = + ScriptWitness(der(takerSig) :: der(makerSig) :: ByteVector.empty :: redeemScript :: Nil) + + /** + * This is the way to finish a swap if the invoice was not paid and the taker did not send a coop_close message. After the relative locktime has passed, the maker refunds to them. + * witness: + */ + def witnessClaimByCsv(makerSig: ByteVector64, redeemScript: ByteVector): ScriptWitness = + ScriptWitness(der(makerSig) :: redeemScript :: Nil) +} \ No newline at end of file diff --git a/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..2bb88c5468 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/SwapTaker.scala @@ -0,0 +1,357 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor +import akka.actor.typed.eventstream.EventStream.Publish +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import akka.util.Timeout +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchOutputSpentTriggered, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentEvent, PaymentFailed, PaymentSent} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapHelpers._ +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{CreateFailed, Error, Fail, InternalError, InvalidMessage, PeerCanceled, SwapError, SwapStatus, UserCanceled} +import fr.acinq.eclair.plugins.peerswap.SwapRole.Taker +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ +import fr.acinq.eclair.{NodeParams, ShortChannelId, ToMilliSatoshiConversion} +import scodec.bits.ByteVector + +import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Success} + +object SwapTaker { + /* + SwapMaker SwapTaker + + "Swap Out" + RESPONDER INITIATOR + | | [createSwap] + | SwapOutRequest | + |<-------------------------------| + [validateRequest] | | [awaitAgreement] + | | + | SwapOutAgreement | + |------------------------------->| + [awaitFeePayment] | | [validateFeeInvoice] + | | + | | [payFeeInvoice] + |<------------------------------>| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Swap In" + INITIATOR RESPONDER + [createSwap] | | + | SwapInRequest | + |------------------------------->| + [awaitAgreement] | | [validateRequest] + | | + | SwapInAgreement | [sendAgreement] + |<-------------------------------| + [createOpeningTx] | | + | | + [awaitOpeningTxConfirmed] | | + | OpeningTxBroadcasted | + |------------------------------->| + | | [awaitOpeningTxConfirmed] + + "Claim With Preimage" + [awaitClaimPayment] | | [validateOpeningTx] + | | + | | [payClaimInvoice] + |<------------------------------>| + | | [claimSwap] (claim_by_invoice) + + "Refund Cooperatively" + | | + | CoopClose | [sendCoopClose] + |<-------------------------------| + (claim_by_coop) [claimSwapCoop] | | + + "Refund After Csv" + [waitCsv] | | + | | + (claim_by_csv) [claimSwapCsv] | | + + */ + + def apply(nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb): Behavior[SwapCommand] = + Behaviors.setup { context => + Behaviors.receiveMessagePartial { + case StartSwapOutSender(amount, swapId, shortChannelId) => + new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) + .createSwap(amount, swapId) + case StartSwapInReceiver(request: SwapInRequest) => + ShortChannelId.fromCoordinates(request.scid) match { + case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) + .validateRequest(request) + case Failure(e) => context.log.error(s"received swap request with invalid shortChannelId: $request, $e") + Behaviors.stopped + } + case RestoreSwap(d) => + ShortChannelId.fromCoordinates(d.request.scid) match { + case Success(shortChannelId) => new SwapTaker(shortChannelId, nodeParams, paymentInitiator, watcher, register, wallet, keyManager, db, context) + .awaitOpeningTxConfirmed(d.request, d.agreement, d.openingTxBroadcasted, d.isInitiator) + case Failure(e) => context.log.error(s"could not restore swap receiver with invalid shortChannelId: $d, $e") + Behaviors.stopped + } + case AbortSwap => Behaviors.stopped + } + } +} + +private class SwapTaker(shortChannelId: ShortChannelId, nodeParams: NodeParams, paymentInitiator: actor.ActorRef, watcher: ActorRef[ZmqWatcher.Command], register: actor.ActorRef, wallet: OnChainWallet, keyManager: SwapKeyManager, db: SwapsDb, implicit val context: ActorContext[SwapCommands.SwapCommand]) { + val protocolVersion = 2 + val noAsset = "" + implicit val timeout: Timeout = 30 seconds + + private val feeRatePerKw: FeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + private val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat // TODO: how should swap receiver calculate an acceptable premium? + private val maxOpeningFee = (feeRatePerKw * openingTxWeight / 1000).toLong.sat // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? + private def takerPrivkey(swapId: String): PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + private def takerPubkey(swapId: String): PublicKey = takerPrivkey(swapId).publicKey + private def makerPubkey(request: SwapRequest, agreement: SwapAgreement, isInitiator: Boolean): PublicKey = + PublicKey(ByteVector.fromValidHex( + if (isInitiator) { + agreement.pubkey + } else { + request.pubkey + })) + + private def createSwap(amount: Satoshi, swapId: String): Behavior[SwapCommand] = { + // a finalized scid must exist for the channel to create a swap + val request = SwapOutRequest(protocolVersion, swapId, noAsset, NodeParams.chainFromHash(nodeParams.chainHash), shortChannelId.toString, amount.toLong, takerPubkey(swapId).toHex) + awaitAgreement(request) + } + + private def awaitAgreement(request: SwapOutRequest): Behavior[SwapCommand] = { + // TODO: why do we not get a ForwardFailure message when channel is not connected? + sendShortId(register, shortChannelId)(request) + + receiveSwapMessage[AwaitAgreementMessages](context, "awaitAgreement") { + case SwapMessageReceived(agreement: SwapOutAgreement) if agreement.protocolVersion != protocolVersion => + swapCanceled(InternalError(request.swapId, s"protocol version must be $protocolVersion.")) + case SwapMessageReceived(agreement: SwapOutAgreement) => validateFeeInvoice(request, agreement) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during awaitAgreement")) + case ForwardFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap request to peer.")) + case SwapMessageReceived(m) => swapCanceled(InvalidMessage(request.swapId, "awaitAgreement", m)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(UserCanceled(request.swapId)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitAgreement", request) + Behaviors.same + } + } + + def validateFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement): Behavior[SwapCommand] = { + Bolt11Invoice.fromString(agreement.payreq) match { + case Success(i) if i.amount_opt.isDefined && i.amount_opt.get > maxOpeningFee => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Invoice amount ${i.amount_opt} > estimated opening tx fee $maxOpeningFee")) + case Success(i) if i.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Channel hop other than $shortChannelId found in invoice hints ${i.routingInfo}")) + case Success(i) if i.isExpired() => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Invoice is expired.")) + case Success(i) if i.amount_opt.isEmpty || i.amount_opt.get > maxOpeningFee => + swapCanceled(CreateFailed(request.swapId, s"invalid invoice: unacceptable opening fee requested.")) + case Success(feeInvoice) => payFeeInvoice(request, agreement, feeInvoice) + case Failure(e) => swapCanceled(CreateFailed(request.swapId, s"invalid invoice: Could not parse payreq: $e")) + } + } + + def payFeeInvoice(request: SwapOutRequest, agreement: SwapOutAgreement, feeInvoice: Bolt11Invoice): Behavior[SwapCommand] = { + watchForPayment(watch = true) // subscribe to payment event notifications + payInvoice(nodeParams)(paymentInitiator, request.swapId, feeInvoice) + + receiveSwapMessage[PayFeeInvoiceMessages](context, "payOpeningTxFeeInvoice") { + // TODO: add counter party to naughty list if they do not send openingTxBroadcasted and publish a valid opening tx after we pay the fee invoice + case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != feeInvoice.paymentHash => Behaviors.same + case PaymentEventReceived(_: PaymentSent) => Behaviors.same + case PaymentEventReceived(p: PaymentFailed) => swapCanceled(CreateFailed(request.swapId, s"Lightning payment failed: $p")) + case PaymentEventReceived(p: PaymentEvent) => swapCanceled(CreateFailed(request.swapId, s"Lightning payment failed, invalid PaymentEvent received: $p.")) + case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = true) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) + case SwapMessageReceived(m) => swapCanceled(CreateFailed(request.swapId, s"Invalid message received during payOpeningTxFeeInvoice: $m")) + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during payFeeInvoice")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCanceled(CreateFailed(request.swapId, s"Cancel requested by user while validating opening tx.")) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payFeeInvoice", request, Some(agreement), None, None) + Behaviors.same + } + } + + def validateRequest(request: SwapInRequest): Behavior[SwapCommand] = { + // fail if swap request is invalid, otherwise respond with agreement + if (request.protocolVersion != protocolVersion || request.asset != noAsset || request.network != NodeParams.chainFromHash(nodeParams.chainHash)) { + swapCanceled(InternalError(request.swapId, s"incompatible request: $request.")) + } else { + sendAgreement(request, SwapInAgreement(protocolVersion, request.swapId, takerPubkey(request.swapId).toHex, premium.toLong)) + } + } + + private def sendAgreement(request: SwapInRequest, agreement: SwapInAgreement): Behavior[SwapCommand] = { + // TODO: SHOULD fail any htlc that would change the channel into a state, where the swap invoice can not be payed until the swap invoice was payed. + sendShortId(register, shortChannelId)(agreement) + + receiveSwapMessage[SendAgreementMessages](context, "sendAgreement") { + case SwapMessageReceived(openingTxBroadcasted: OpeningTxBroadcasted) => awaitOpeningTxConfirmed(request, agreement, openingTxBroadcasted, isInitiator = false) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) + case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during sendAgreement: $m") + case StateTimeout => swapCanceled(InternalError(request.swapId, "timeout during sendAgreement")) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap agreement to peer.")) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user after sending agreement.") + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "sendAgreement", request, Some(agreement)) + Behaviors.same + } + } + + def awaitOpeningTxConfirmed(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, isInitiator: Boolean): Behavior[SwapCommand] = { + def openingConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](OpeningTxConfirmed) + watchForTxConfirmation(watcher)(openingConfirmedAdapter, ByteVector32(ByteVector.fromValidHex(openingTxBroadcasted.txId)), 3) // watch for opening tx to be confirmed + + receiveSwapMessage[AwaitOpeningTxConfirmedMessages](context, "awaitOpeningTxConfirmed") { + case OpeningTxConfirmed(opening) => validateOpeningTx(request, agreement, openingTxBroadcasted, opening.tx, isInitiator) + case SwapMessageReceived(cancel: CancelSwap) => swapCanceled(PeerCanceled(request.swapId, cancel.message)) + case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during awaitOpeningTxConfirmed: $m") + case InvoiceExpired => sendCoopClose(request, "Timeout waiting for opening tx to confirm.") + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user while waiting for opening tx to confirm.") + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "awaitOpeningTxConfirmed", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def validateOpeningTx(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { + Bolt11Invoice.fromString(openingTxBroadcasted.payreq) match { + case Success(i) if i.amount_opt.isDefined && i.amount_opt.get > request.amount.sat.toMilliSatoshi => + context.self ! InvalidInvoice(s"Invoice amount ${i.amount_opt} > requested amount ${request.amount}") + case Success(i) if i.routingInfo.flatten.exists(hop => hop.shortChannelId != shortChannelId) => + context.self ! InvalidInvoice(s"Channel hop other than $shortChannelId found in invoice hints ${i.routingInfo}") + case Success(i) if i.isExpired() => + context.self ! InvalidInvoice(s"Invoice is expired.") + case Success(i) => context.self ! ValidInvoice(i) + case Failure(e) => context.self ! InvalidInvoice(s"Could not parse payreq: $e") + } + + receiveSwapMessage[ValidateTxMessages](context, "validateOpeningTx") { + case ValidInvoice(invoice) if validOpeningTx(openingTx, openingTxBroadcasted.scriptOut, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) => + db.add(SwapData(request, agreement, invoice, openingTxBroadcasted, Taker, isInitiator)) + payClaimInvoice(request, agreement, openingTxBroadcasted, invoice, openingTx, isInitiator) + case ValidInvoice(_) => sendCoopClose(request,s"Invalid opening tx: $openingTx", Some(openingTxBroadcasted)) + case InvalidInvoice(reason) => sendCoopClose(request, reason, Some(openingTxBroadcasted)) + case SwapMessageReceived(m) => sendCoopClose(request, s"Invalid message received during validateOpeningTx: $m", Some(openingTxBroadcasted)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user while validating opening tx.", Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "validateOpeningTx", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def payClaimInvoice(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { + watchForPayment(watch = true) // subscribe to payment event notifications + payInvoice(nodeParams)(paymentInitiator, request.swapId, invoice) + + receiveSwapMessage[PayClaimInvoiceMessages](context, "payClaimInvoice") { + case PaymentEventReceived(p: PaymentEvent) if p.paymentHash != invoice.paymentHash => Behaviors.same + case PaymentEventReceived(p: PaymentSent) => claimSwap(request, agreement, openingTxBroadcasted, invoice, p.paymentPreimage, openingTx, isInitiator) + case PaymentEventReceived(p: PaymentFailed) => sendCoopClose(request, s"Lightning payment failed: $p", Some(openingTxBroadcasted)) + case PaymentEventReceived(p: PaymentEvent) => sendCoopClose(request, s"Lightning payment failed (invalid PaymentEvent received: $p).", Some(openingTxBroadcasted)) + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + sendCoopClose(request, s"Cancel requested by user while paying claim invoice.", Some(openingTxBroadcasted)) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "payClaimInvoice", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def claimSwap(request: SwapRequest, agreement: SwapAgreement, openingTxBroadcasted: OpeningTxBroadcasted, invoice: Bolt11Invoice, paymentPreimage: ByteVector32, openingTx: Transaction, isInitiator: Boolean): Behavior[SwapCommand] = { + val inputInfo = makeSwapOpeningInputInfo(openingTx.txid, openingTxBroadcasted.scriptOut.toInt, (request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPubkey(request.swapId), invoice.paymentHash) + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey(request, agreement, isInitiator), takerPrivkey(request.swapId), paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) + def claimByInvoiceConfirmedAdapter: ActorRef[WatchTxConfirmedTriggered] = context.messageAdapter[WatchTxConfirmedTriggered](ClaimTxConfirmed) + + watchForTxConfirmation(watcher)(claimByInvoiceConfirmedAdapter, claimByInvoiceTx.txid, nodeParams.channelConf.minDepthBlocks) + watchForPayment(watch = false) // unsubscribe from payment event notifications + commitClaim(wallet)(request.swapId, SwapClaimByInvoiceTx(inputInfo, claimByInvoiceTx), "swap-in-receiver-claimbyinvoice") + + receiveSwapMessage[ClaimSwapMessages](context, "claimSwap") { + case ClaimTxCommitted => Behaviors.same + case ClaimTxConfirmed(confirmedTriggered) => swapCompleted(ClaimByInvoiceConfirmed(request.swapId, confirmedTriggered)) + case SwapMessageReceived(m) => context.log.warn(s"received swap unhandled message while in state claimSwap: $m") + Behaviors.same + case ClaimTxFailed(error) => context.log.error(s"swap $request.swapId claim by invoice tx failed, error: $error") + Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? + case ClaimTxInvalid(e) => context.log.error(s"swap $request.swapId claim by invoice tx is invalid: $e, tx: $claimByInvoiceTx") + Behaviors.same // TODO: handle when claim tx not confirmed, retry the tx? + case StateTimeout => Behaviors.same // TODO: handle when claim tx not confirmed, retry or RBF the tx? can SwapInSender pin this tx with a low fee? + case CancelRequested(replyTo) => replyTo ! SwapError(request.swapId, "Can not cancel swap after claim tx committed.") + Behaviors.same // ignore + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "claimSwap", request, Some(agreement), None, Some(openingTxBroadcasted)) + Behaviors.same + } + } + + def sendCoopClose(request: SwapRequest, reason: String, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None): Behavior[SwapCommand] = { + context.log.error(s"swap ${request.swapId} sent coop close, reason: $reason") + sendShortId(register, shortChannelId)(CoopClose(request.swapId, reason, takerPrivkey(request.swapId).toHex)) + def openingTxSpentAdapter: ActorRef[WatchOutputSpentTriggered] = context.messageAdapter[WatchOutputSpentTriggered](OpeningTxOutputSpent) + openingTxBroadcasted_opt match { + case Some(m) => watchForOutputSpent(watcher)(openingTxSpentAdapter, ByteVector32(ByteVector.fromValidHex(m.txId)), m.scriptOut.toInt) + receiveSwapMessage[SendCoopCloseMessages](context, "sendCoopClose") { + case OpeningTxOutputSpent(_) => swapCompleted(ClaimByCoopOffered(request.swapId, reason)) + case ForwardShortIdFailureAdapter(_) => swapCanceled(InternalError(request.swapId, s"could not forward swap coop close to peer.")) + // TODO: set long enough timeout delay to wait for counterparty to sweep opening tx + case CancelRequested(replyTo) => replyTo ! UserCanceled(request.swapId) + swapCompleted(ClaimByCoopOffered(request.swapId, reason + "+ user canceled while waiting for opening tx to be swept by counter party.")) + case GetStatus(replyTo) => replyTo ! SwapStatus(request.swapId, context.self.toString, "sendCoopClose", request, None, None, openingTxBroadcasted_opt) + Behaviors.same + } + case None => swapCompleted(ClaimByCoopOffered(request.swapId, reason)) + } + } + + def swapCompleted(event: SwapEvent): Behavior[SwapCommand] = { + context.system.eventStream ! Publish(event) + context.log.info(s"completed swap: $event.") + Behaviors.stopped + } + + def swapCanceled(failure: Fail): Behavior[SwapCommand] = { + val swapEvent = Canceled(failure.swapId, failure.toString) + context.system.eventStream ! Publish(swapEvent) + failure match { + case e: Error => context.log.error(s"canceled swap: $e") + case s: CreateFailed => sendShortId(register, shortChannelId)(CancelSwap(s.swapId, s.toString)) + context.log.info(s"canceled swap: $s") + case s: Fail => context.log.info(s"canceled swap: $s") + case _ => context.log.error(s"canceled swap ${failure.swapId}, reason: unknown.") + } + Behaviors.stopped + } + +} \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala new file mode 100644 index 0000000000..54ec84eae3 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/DualSwapsDb.scala @@ -0,0 +1,55 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.db + +import com.google.common.util.concurrent.ThreadFactoryBuilder +import fr.acinq.eclair.db.DualDatabases.runAsync +import fr.acinq.eclair.plugins.peerswap.SwapData +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent + +import java.util.concurrent.Executors +import scala.concurrent.ExecutionContext + +case class DualSwapsDb(primary: SwapsDb, secondary: SwapsDb) extends SwapsDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build())) + + override def add(swapData: SwapData): Unit = { + runAsync(secondary.add(swapData)) + primary.add(swapData) + } + + override def addResult(swapEvent: SwapEvent): Unit = { + runAsync(secondary.addResult(swapEvent)) + primary.addResult(swapEvent) + } + + override def remove(swapId: String): Unit = { + runAsync(secondary.remove(swapId)) + primary.remove(swapId) + } + + override def restore(): Seq[SwapData] = { + runAsync(secondary.restore()) + primary.restore() + } + + override def list(): Seq[SwapData] = { + runAsync(secondary.list()) + primary.list() + } +} \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala new file mode 100644 index 0000000000..f36da50f1e --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDb.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.db + +import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.SwapRole.Maker +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ +import fr.acinq.eclair.plugins.peerswap.{SwapData, SwapRole} +import org.json4s.jackson.JsonMethods.{compact, parse, render} +import org.json4s.jackson.Serialization + +import java.sql.{PreparedStatement, ResultSet} + +trait SwapsDb { + + def add(swapData: SwapData): Unit + + def addResult(swapEvent: SwapEvent): Unit + + def remove(swapId: String): Unit + + def restore(): Seq[SwapData] + + def list(): Seq[SwapData] + +} + +object SwapsDb { + import fr.acinq.eclair.json.JsonSerializers.formats + + def setSwapData(statement: PreparedStatement, swapData: SwapData): Unit = { + statement.setString(1, swapData.request.swapId) + statement.setString(2, Serialization.write(swapData.request)) + statement.setString(3, Serialization.write(swapData.agreement)) + statement.setString(4, swapData.invoice.toString) + statement.setString(5, Serialization.write(swapData.openingTxBroadcasted)) + statement.setInt(6, swapData.swapRole.id) + statement.setBoolean(7, swapData.isInitiator) + statement.setString(8, "") + } + + def getSwapData(rs: ResultSet): SwapData = { + val isInitiator = rs.getBoolean("is_initiator") + val isMaker = SwapRole(rs.getInt("swap_role")) == Maker + val request_json = rs.getString("request") + val agreement_json = rs.getString("agreement") + val openingTxBroadcasted_json = rs.getString("opening_tx_broadcasted") + val (request, agreement) = (isInitiator, isMaker) match { + case (true, true) => (Serialization.read[SwapInRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapInAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + case (false, false) => (Serialization.read[SwapInRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapInAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + case (true, false) => (Serialization.read[SwapOutRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapOutAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + case (false, true) => (Serialization.read[SwapOutRequest](compact(render(parse(request_json).camelizeKeys))), + Serialization.read[SwapOutAgreement](compact(render(parse(agreement_json).camelizeKeys)))) + } + SwapData( + request, + agreement, + Bolt11Invoice.fromString(rs.getString("invoice")).get, + Serialization.read[OpeningTxBroadcasted](compact(render(parse(openingTxBroadcasted_json).camelizeKeys))), + SwapRole(rs.getInt("swap_role")), + rs.getBoolean("is_initiator")) + } +} \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala new file mode 100644 index 0000000000..8b265b5a8a --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/pg/PgSwapsDb.scala @@ -0,0 +1,100 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.db.pg + +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock +import fr.acinq.eclair.plugins.peerswap.SwapData +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb.{getSwapData, setSwapData} +import grizzled.slf4j.Logging + +import javax.sql.DataSource + +object PgSwapsDb { + val DB_NAME = "swaps" + val CURRENT_VERSION = 1 +} + +class PgSwapsDb(implicit ds: DataSource) extends SwapsDb with Logging { + + import fr.acinq.eclair.db.pg.PgUtils._ + import ExtendedResultSet._ + import PgSwapsDb._ + + inTransaction { pg => + using(pg.createStatement(), inTransaction = true) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE TABLE swaps (swap_id TEXT NOT NULL PRIMARY KEY, request TEXT NOT NULL, agreement TEXT NOT NULL, invoice TEXT NOT NULL, opening_tx_broadcasted TEXT NOT NULL, swap_role BIGINT NOT NULL, is_initiator BOOLEAN NOT NULL, result TEXT NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + } + + override def add(swapData: SwapData): Unit = withMetrics("swaps/add", DbBackends.Postgres) { + inTransaction { pg => + using(pg.prepareStatement( + """INSERT INTO swaps (swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result) + VALUES (?, ?::JSON, ?::JSON, ?, ?::JSON, ?, ?, ?) ON CONFLICT (swap_id) DO NOTHING""")) { statement => + setSwapData(statement, swapData) + statement.executeUpdate() + } + } + } + + override def addResult(swapEvent: SwapEvent): Unit = withMetrics("swaps/add_result", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("UPDATE swaps SET result=? WHERE swap_id=?")) { statement => + statement.setString(1, swapEvent.toString) + statement.setString(2, swapEvent.swapId) + statement.executeUpdate() + } + } + } + + override def remove(swapId: String): Unit = withMetrics("swaps/remove", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("DELETE FROM swaps WHERE swap_id=?")) { statement => + statement.setString(1, swapId) + statement.executeUpdate() + } + } + } + + override def restore(): Seq[SwapData] = withMetrics("swaps/restore", DbBackends.Postgres) { + inTransaction { pg => + using(pg.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps WHERE result=?")) { statement => + statement.setString(1, "") + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + } + + override def list(): Seq[SwapData] = withMetrics("swaps/list", DbBackends.Postgres) { + inTransaction { pg => + using(pg.prepareStatement("SELECT request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps")) { statement => + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + } + +} \ No newline at end of file diff --git a/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala new file mode 100644 index 0000000000..5bf480d0ac --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/db/sqlite/SqliteSwapsDb.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.db.sqlite + +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +import fr.acinq.eclair.plugins.peerswap.SwapData +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb +import fr.acinq.eclair.plugins.peerswap.db.SwapsDb.{getSwapData, setSwapData} +import grizzled.slf4j.Logging + +import java.sql.Connection + +object SqliteSwapsDb { + val DB_NAME = "swaps" + val CURRENT_VERSION = 1 +} + +class SqliteSwapsDb (val sqlite: Connection) extends SwapsDb with Logging { + + import fr.acinq.eclair.db.sqlite.SqliteUtils._ + import ExtendedResultSet._ + import SqliteSwapsDb._ + + using(sqlite.createStatement(), inTransaction = true) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE TABLE swaps (swap_id STRING NOT NULL PRIMARY KEY, request STRING NOT NULL, agreement STRING NOT NULL, invoice STRING NOT NULL, opening_tx_broadcasted STRING NOT NULL, swap_role INTEGER NOT NULL, is_initiator BOOLEAN NOT NULL, result STRING NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + + override def add(swapData: SwapData): Unit = withMetrics("swaps/add", DbBackends.Sqlite) { + using(sqlite.prepareStatement( + """INSERT INTO swaps (swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (swap_id) DO NOTHING""")) { statement => + setSwapData(statement, swapData) + statement.executeUpdate() + } + } + + override def addResult(swapEvent: SwapEvent): Unit = withMetrics("swaps/add_result", DbBackends.Sqlite) { + using(sqlite.prepareStatement("UPDATE swaps SET result=? WHERE swap_id=?")) { statement => + statement.setString(1, swapEvent.toString) + statement.setString(2, swapEvent.swapId) + statement.executeUpdate() + } + } + + override def remove(swapId: String): Unit = withMetrics("swaps/remove", DbBackends.Sqlite) { + using(sqlite.prepareStatement("DELETE FROM swaps WHERE swap_id=?")) { statement => + statement.setString(1, swapId) + statement.executeUpdate() + } + } + + override def restore(): Seq[SwapData] = withMetrics("swaps/restore", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps WHERE result=?")) { statement => + statement.setString(1, "") + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + + override def list(): Seq[SwapData] = withMetrics("swaps/list", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT swap_id, request, agreement, invoice, opening_tx_broadcasted, swap_role, is_initiator, result FROM swaps")) { statement => + statement.executeQuery().map(rs => getSwapData(rs)).toSeq + } + } + +} \ No newline at end of file diff --git a/plugins/peerswap/src/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/SwapTransactions.scala b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala new file mode 100644 index 0000000000..a0a399dba0 --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactions.scala @@ -0,0 +1,162 @@ +/* + * 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.{TxOut, _} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.plugins.peerswap.SwapScripts._ +import fr.acinq.eclair.transactions.Scripts.der +import fr.acinq.eclair.transactions.Transactions.{InputInfo, TransactionWithInputInfo, weight2fee} +import scodec.bits.ByteVector + +object SwapTransactions { + + // TODO: find alternative to unsealing TransactionWithInputInfo + case class SwapClaimByInvoiceTx(override val input: InputInfo, override val tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbyinvoice-tx" } + case class SwapClaimByCoopTx(override val input: InputInfo, override val tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycoop-tx" } + case class SwapClaimByCsvTx(override val input: InputInfo, override val tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "swap-claimbycsv-tx" } + + /** + * This default sig takes 72B when encoded in DER (incl. 1B for the trailing sig hash), it is used for fee estimation + * It is 72 bytes because our signatures are normalized (low-s) and will take up 72 bytes at most in DER format + */ + val PlaceHolderSig: ByteVector64 = ByteVector64(ByteVector.fill(64)(0xaa)) + assert(der(PlaceHolderSig).size == 72) + + val claimByInvoiceTxWeight = 593 // TODO: add test to confirm this is the actual weight of the claimByInvoice tx in vBytes + val openingTxWeight = 610 // TODO: compute and add test to confirm this is the actual weight of the opening tx in vBytes + + def makeSwapOpeningInputInfo(fundingTxId: ByteVector32, fundingTxOutputIndex: Int, amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): InputInfo = { + val redeemScript = swapOpening(makerPubkey, takerPubkey, paymentHash) + val openingTxOut = makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash) + InputInfo(OutPoint(fundingTxId.reverse, fundingTxOutputIndex), openingTxOut, write(redeemScript)) + } + + def makeSwapOpeningTxOut(amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): TxOut = { + val redeemScript = swapOpening(makerPubkey, takerPubkey, paymentHash) + TxOut(amount, pay2wsh(redeemScript)) + } + + def validOpeningTx(openingTx: Transaction, scriptOut: Long, amount: Satoshi, makerPubkey: PublicKey, takerPubkey: PublicKey, paymentHash: ByteVector32): Boolean = + openingTx match { + case Transaction(2, _, txOut, 0) if txOut(scriptOut.toInt) == makeSwapOpeningTxOut(amount, makerPubkey, takerPubkey, paymentHash) => true + case _ => false + } + + /** + * This is the desired way to finish a swap. The taker sends the funds to its address by revealing the preimage of the swap invoice. + * + * txin count: 1 + * txin[0] outpoint: tx_id and script_output from the opening_tx_broadcasted message + * txin[0] sequence: 0 + * txin[0] script bytes: 0 + * txin[0] witness: <> <> + * + */ + def makeSwapClaimByInvoiceTx(amount: Satoshi, makerPubkey: PublicKey, takerPrivkey: PrivateKey, paymentPreimage: ByteVector32, feeratePerKw: FeeratePerKw, openingTxId: ByteVector32, openingOutIndex: Int): Transaction = { + val redeemScript = swapOpening(makerPubkey, takerPrivkey.publicKey, Crypto.sha256(paymentPreimage)) + + val tx = Transaction( + version = 2, + txIn = TxIn(OutPoint(openingTxId.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..b55588150c --- /dev/null +++ b/plugins/peerswap/src/main/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageTypes.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.wire.protocol + +import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers +import org.json4s.jackson.Serialization +import scodec.bits.ByteVector + +sealed trait HasSwapId extends Serializable { def swapId: String } + +sealed abstract class JSonBlobMessage() extends HasSwapId { + def json: String = { + Serialization.write(this)(PeerSwapJsonSerializers.formats) + } +} + +sealed trait HasSwapVersion { def protocolVersion: Long} + +sealed trait SwapRequest extends JSonBlobMessage with HasSwapId with HasSwapVersion { + def asset: String + def network: String + def scid: String + def amount: Long + def pubkey: String +} + +case class SwapInRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest + +case class SwapOutRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends SwapRequest + +sealed trait SwapAgreement extends JSonBlobMessage with HasSwapId with HasSwapVersion { + def pubkey: String + def premium: Long + def payreq: String +} + +case class SwapInAgreement(protocolVersion: Long, swapId: String, pubkey: String, premium: Long) extends SwapAgreement { + override def payreq: String = "" +} + +case class SwapOutAgreement(protocolVersion: Long, swapId: String, pubkey: String, payreq: String) extends SwapAgreement { + override def premium: Long = 0 +} + +case class OpeningTxBroadcasted(swapId: String, payreq: String, txId: String, scriptOut: Long, blindingKey: String) extends JSonBlobMessage with HasSwapId + +case class CancelSwap(swapId: String, message: String) extends JSonBlobMessage with HasSwapId + +case class CoopClose(swapId: String, message: String, privkey: String) extends JSonBlobMessage with HasSwapId + +case class UnknownPeerSwapMessage(tag: Int, data: ByteVector) extends HasSwapId { + def swapId: String = "unknown" +} diff --git a/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..ab12f95854 --- /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 = 2 + val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" + val asset = "" + val network = "regtest" + val shortId: ShortChannelId = ShortChannelId.fromCoordinates("539268x845x1").success.get + val amount = 10000 + val pubkey: PublicKey = dummyKey(1).publicKey + val premium = 1000 + val payreq = "invoice here" + val txid = "38b854c569ff4b8b25e6eeec31d21ce4a1ee6dbc2afc7efdb44c81d513b4bffc" + val scriptOut = 0 + val blindingKey = "" + val message = "a message" + val privkey: PrivateKey = dummyKey(1) + + def dummyKey(fill: Byte): Crypto.PrivateKey = PrivateKey(ByteVector.fill(32)(fill)) + + 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..8527c532a8 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInReceiverSpec.scala @@ -0,0 +1,209 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.swapInAgreementCodec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import java.sql.DriverManager +import java.util.UUID +import scala.concurrent.duration._ + +// with BitcoindService +case class SwapInReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val 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"), CltvExpiryDelta(18)) + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + + val wallet = new DummyOnChainWallet() + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + + // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapInReceiver = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-in-receiver") + + withFixture(test.toNoArgTest(FixtureParam(swapInReceiver, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapInReceiver: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path from restored swap in") { f => + import f._ + + // restore the SwapInReceiver actor state from a confirmed on-chain opening tx + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val agreement = SwapInAgreement(protocolVersion, swapId, takerPubkey.toHex, premium) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Taker, isInitiator = false) + swapInReceiver ! RestoreSwap(swapData) + monitor.expectMessageType[RestoreSwap] + + // SwapInReceiver reports status of awaiting opening transaction + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "awaitOpeningTxConfirmed") + + // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut((request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash)), 0) + swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + monitor.expectMessageType[OpeningTxConfirmed] + + // SwapInReceiver validates invoice and opening transaction before paying the invoice + monitor.expectMessageType[ValidInvoice] + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + + // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver ignores payments that do not correspond to the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), ByteVector32.Zeroes, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + monitor.expectMessageType[PaymentEventReceived].paymentEvent + monitor.expectNoMessage() + + // SwapInReceiver commits a claim-by-invoice transaction after successfully paying the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice.paymentHash, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val paymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(paymentEvent.isInstanceOf[PaymentSent] && paymentEvent.paymentHash === invoice.paymentHash) + monitor.expectMessage(ClaimTxCommitted) + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + monitor.expectMessageType[ClaimTxConfirmed] + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + } + + test("happy path for new swap in") { f => + import f._ + + // start new SwapInReceiver + swapInReceiver ! StartSwapInReceiver(request) + monitor.expectMessage(StartSwapInReceiver(request)) + + // SwapInReceiver:SwapInAgreement -> SwapInSender + val agreement = swapInAgreementCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + + // Maker:OpeningTxBroadcasted -> Taker + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + swapInReceiver ! SwapMessageReceived(openingTxBroadcasted) + monitor.expectMessageType[SwapMessageReceived] + + // ZmqWatcher -> SwapInReceiver, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut((request.amount + agreement.premium).sat, makerPubkey, takerPubkey, invoice.paymentHash)), 0) + swapInReceiver ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + monitor.expectMessageType[OpeningTxConfirmed] + + // SwapInReceiver validates invoice and opening transaction before paying the invoice + monitor.expectMessageType[ValidInvoice] + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + + // wait for SwapInReceiver to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver ignores payments that do not correspond to the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), ByteVector32.Zeroes, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + monitor.expectMessageType[PaymentEventReceived].paymentEvent + monitor.expectNoMessage() + + // SwapInReceiver commits a claim-by-invoice transaction after successfully paying the invoice from SwapInSender + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice.paymentHash, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val paymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(paymentEvent.isInstanceOf[PaymentSent] && paymentEvent.paymentHash === invoice.paymentHash) + monitor.expectMessage(ClaimTxCommitted) + + // SwapInReceiver reports status of awaiting claim by invoice tx to confirm + swapInReceiver ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwap") + + // SwapInReceiver reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx((request.amount + agreement.premium).sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) + swapInReceiver ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + monitor.expectMessageType[ClaimTxConfirmed] + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInReceiver) + } +} 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..7759dfb554 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapInSenderSpec.scala @@ -0,0 +1,245 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapInRequestCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{CoopClose, OpeningTxBroadcasted, SwapInAgreement, SwapInRequest} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import java.sql.DriverManager +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} + +// with BitcoindService +case class SwapInSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + val makerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + val takerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val makerPubkey: PublicKey = makerPrivkey.publicKey + val takerPubkey: PublicKey = takerPrivkey.publicKey + val premium = 10 + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapInRequest = SwapInRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + val agreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId, makerPubkey.toHex, premium) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + val wallet = new DummyOnChainWallet() { + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(6930 sat, 0 sat)) + } + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + + // subscribe to notification events from SwapInSender when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-in-sender") + + withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path from restored swap") { f => + import f._ + + // restore the SwapInSender actor state from a confirmed on-chain opening tx + val invoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), ByteVector32.One, makerPrivkey, Left("SwapInSender invoice"), CltvExpiryDelta(18)) + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, invoice.toString, txid, scriptOut, blindingKey) + val swapData = SwapData(request, agreement, invoice, openingTxBroadcasted, swapRole = SwapRole.Maker, isInitiator = true) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + register.expectMessageType[ForwardShortId[OpeningTxBroadcasted]] + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // subscribe to notification when SwapInSender successfully receives payment + val paymentEvent = testKit.createTestProbe[PaymentReceived]() + testKit.system.eventStream ! Subscribe(paymentEvent.ref) + + // SwapInSender receives a payment with the corresponding payment hash + val paymentReceived = PaymentReceived(invoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapInSender reports a successful payment + paymentEvent.expectMessageType[PaymentReceived] + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByInvoicePaid] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapInSender) + } + + test("happy path for new swap") { f => + import f._ + + // start new SwapInSender + swapInSender ! StartSwapInSender(amount, swapId, shortChannelId) + + // SwapInSender: SwapInRequest -> SwapInSender + val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + + // SwapInReceiver: SwapInAgreement -> SwapInSender + swapInSender ! SwapMessageReceived(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, takerPubkey.toString(), premium)) + + // SwapInSender publishes opening tx on-chain + val openingTx = swapEvents.expectMessageType[TransactionPublished].tx + + // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver + val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + val invoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInSender reports status of awaiting payment + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") + + // SwapInSender receives a payment with the corresponding payment hash + // TODO: convert from ShortChannelId to ByteVector32 + val paymentReceived = PaymentReceived(invoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByInvoicePaid] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } + + 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) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInReceiver: CoopClose -> SwapInSender + swapInSender ! SwapMessageReceived(CoopClose(swapId, "oops", takerPrivkey.toHex)) + + // SwapInSender confirms that opening tx on-chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, Transaction(2, Seq(), Seq(), 0)) + + // SwapInSender reports status of awaiting claim by cooperative close tx to confirm + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwapCoop") + + // ZmqWatcher -> SwapInSender, trigger confirmation of coop close transaction + swapEvents.expectMessageType[TransactionPublished] + swapInSender ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0))) + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByCoopConfirmed] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } + + 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) + swapInSender ! RestoreSwap(swapData) + + // resend OpeningTxBroadcasted when swap restored + openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + + // wait to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // watch for and trigger that the opening tx has been buried by csv delay blocks + watcher.expectMessageType[WatchFundingDeeplyBuried].replyTo ! WatchFundingDeeplyBuriedTriggered(BlockHeight(0), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0)) + + // SwapInSender reports status of awaiting claim by csv tx to confirm + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapStatus].behavior == "claimSwapCsv") + + // watch for and trigger that the claim-by-csv tx has been confirmed on chain + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(0), scriptOut.toInt, Transaction(2, Seq(), Seq(), 0)) + + // SwapInSender reports a successful csv close + swapEvents.expectMessageType[TransactionPublished] + swapEvents.expectMessageType[ClaimByCsvConfirmed] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } + +} diff --git a/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala new file mode 100644 index 0000000000..7929e6c67c --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationFixture.scala @@ -0,0 +1,65 @@ +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.ActorSystem +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps} +import akka.actor.typed.{ActorRef, SupervisorStrategy} +import akka.testkit.{TestKit, TestProbe} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchExternalChannelSpent +import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} +import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture +import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture.{confirmChannel, confirmChannelDeep, connect, getChannelData, getRouterData, openChannel} +import fr.acinq.eclair.payment.PaymentEvent +import fr.acinq.eclair.plugins.peerswap.SwapEvents.SwapEvent +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.{BlockHeight, NodeParams, TestConstants} +import org.scalatest.concurrent.Eventually.eventually + +import java.sql.DriverManager + +case class SwapActors(cli: TestProbe, paymentEvents: TestProbe, swapEvents: TestProbe, swapRegister: ActorRef[SwapRegister.Command]) + +case class SwapIntegrationFixture(system: ActorSystem, alice: MinimalNodeFixture, bob: MinimalNodeFixture, aliceSwap: SwapActors, bobSwap: SwapActors, channelId: ByteVector32) { + implicit val implicitSystem: ActorSystem = system + + def cleanup(): Unit = { + TestKit.shutdownActorSystem(alice.system) + TestKit.shutdownActorSystem(bob.system) + TestKit.shutdownActorSystem(system) + } +} + +object SwapIntegrationFixture { + def swapRegister(node: MinimalNodeFixture): ActorRef[SwapRegister.Command] = { + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, node.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + node.system.spawn(Behaviors.supervise(SwapRegister(node.nodeParams, node.paymentInitiator, node.watcher.ref.toTyped, node.register, node.wallet, keyManager, db, Set())).onFailure(SupervisorStrategy.stop), s"swap-register-${node.nodeParams.alias}") + } + def apply(aliceParams: NodeParams, bobParams: NodeParams): SwapIntegrationFixture = { + val system = ActorSystem("system-test") + val alice = MinimalNodeFixture(aliceParams) + val bob = MinimalNodeFixture(bobParams) + 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..e4e93c16bf --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapIntegrationSpec.scala @@ -0,0 +1,326 @@ +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.typed.scaladsl.adapter._ +import akka.actor.{ActorSystem, Kill} +import akka.testkit.TestProbe +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.eclair.MilliSatoshi.toMilliSatoshi +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.channel.{DATA_NORMAL, RealScidStatus} +import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture +import fr.acinq.eclair.integration.basic.fixtures.composite.TwoNodesFixture +import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentSent} +import fr.acinq.eclair.plugins.peerswap.SwapEvents._ +import fr.acinq.eclair.plugins.peerswap.SwapIntegrationFixture.swapRegister +import fr.acinq.eclair.plugins.peerswap.SwapRegister.{CancelSwapRequested, ListPendingSwaps, SwapInRequested, SwapOutRequested} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.SwapScripts.claimByCsvDelta +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{claimByInvoiceTxWeight, openingTxWeight} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.SwapInAgreement +import fr.acinq.eclair.testutils.FixtureSpec +import fr.acinq.eclair.{BlockHeight, ShortChannelId, randomKey} +import org.scalatest.TestData +import org.scalatest.concurrent.{IntegrationPatience, PatienceConfiguration} +import scodec.bits.HexStringSyntax + +import scala.concurrent.duration.DurationInt + +/** + * This test checks the integration between SwapInSender and SwapInReceiver + */ + +class SwapIntegrationSpec extends FixtureSpec with IntegrationPatience { + + type FixtureParam = TwoNodesFixture + + val SwapIntegrationConfAlice = "swap_integration_conf_alice" + val SwapIntegrationConfBob = "swap_integration_conf_bob" + + import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture._ + + override def createFixture(testData: TestData): FixtureParam = { + // seeds have been chosen so that node ids start with 02aaaa for alice, 02bbbb for bob, etc. + val aliceParams = nodeParamsFor("alice", ByteVector32(hex"b4acd47335b25ab7b84b8c020997b12018592bb4631b868762154d77fa8b93a3")) + .copy(pluginParams = Seq(new PeerSwapPlugin().params)) + val bobParams = nodeParamsFor("bob", ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492")) + .copy(invoiceExpiry = 2 seconds, pluginParams = Seq(new PeerSwapPlugin().params)) + TwoNodesFixture(aliceParams, bobParams) + } + + override def cleanupFixture(fixture: FixtureParam): Unit = { + fixture.cleanup() + } + + def swapActors(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): (SwapActors, SwapActors) = { + val aliceSwap = SwapActors(TestProbe()(alice.system), TestProbe()(alice.system), TestProbe()(alice.system), swapRegister(alice)) + val bobSwap = SwapActors(TestProbe()(bob.system), TestProbe()(bob.system), TestProbe()(bob.system), swapRegister(bob)) + alice.system.eventStream.subscribe(aliceSwap.paymentEvents.ref, classOf[PaymentEvent]) + alice.system.eventStream.subscribe(aliceSwap.swapEvents.ref, classOf[SwapEvent]) + bob.system.eventStream.subscribe(bobSwap.paymentEvents.ref, classOf[PaymentEvent]) + bob.system.eventStream.subscribe(bobSwap.swapEvents.ref, classOf[SwapEvent]) + (aliceSwap, bobSwap) + } + + def connectNodes(alice: MinimalNodeFixture, bob: MinimalNodeFixture)(implicit system: ActorSystem): ShortChannelId = { + connect(alice, bob)(system) + val channelId = openChannel(alice, bob, 100_000 sat)(system).channelId + confirmChannel(alice, bob, channelId, BlockHeight(420_000), 21)(system) + confirmChannelDeep(alice, bob, channelId, BlockHeight(420_000), 21)(system) + val shortChannelId = getChannelData(alice, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.toOption.get + assert(getChannelData(alice, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) + assert(getChannelData(bob, channelId)(system).asInstanceOf[DATA_NORMAL].shortIds.real.isInstanceOf[RealScidStatus.Final]) + + eventually(PatienceConfiguration.Timeout(2 seconds), PatienceConfiguration.Interval(1 second)) { + getRouterData(alice)(system).privateChannels.size == 1 + } + alice.watcher.expectMsgType[WatchExternalChannelSpent] + bob.watcher.expectMsgType[WatchExternalChannelSpent] + + shortChannelId + } + + test("swap in - claim by invoice") { f => + import f._ + + val (aliceSwap, bobSwap) = swapActors(alice, bob) + val shortChannelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByInvoiceBlock = BlockHeight(4) + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx published + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + + // bob has status of 1 pending swap + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // swap in receiver (alice) confirms opening tx on-chain + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + assert(openingTx.txOut.head.amount == amount + premium) + + // swap in receiver (alice) sends a payment of `amount` to swap in sender (bob) + assert(aliceSwap.paymentEvents.expectMsgType[PaymentSent].recipientAmount === toMilliSatoshi(amount)) + assert(bobSwap.paymentEvents.expectMsgType[PaymentReceived].amount === toMilliSatoshi(amount)) + + // swap in receiver (alice) confirms claim-by-invoice tx published + val claimTx = aliceSwap.swapEvents.expectMsgType[TransactionPublished].tx + assert(claimTx.txOut.head.amount == amount) // added on-chain premium consumed as tx fee + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByInvoiceBlock, 0, claimTx) + + // both parties publish that the swap was completed via claim-by-invoice + assert(aliceSwap.swapEvents.expectMsgType[ClaimByInvoiceConfirmed].swapId == swapId) + assert(bobSwap.swapEvents.expectMsgType[ClaimByInvoicePaid].swapId == swapId) + } + + test("swap in - claim by coop, receiver does not have sufficient channel balance") { f => + import f._ + + val (aliceSwap, bobSwap) = swapActors(alice, bob) + val shortChannelId = connectNodes(alice, bob) + + // swap more satoshis than alice has available in the channel to send to bob + val amount = 100_000 sat + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByCoopBlock = BlockHeight(2) + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx published + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount + premium) + + // bob has status of 1 pending swap + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // alice has status of 1 pending swap + aliceSwap.swapRegister ! ListPendingSwaps(aliceSwap.cli.ref.toTyped) + val aliceStatus = aliceSwap.cli.expectMsgType[Iterable[Status]] + assert(aliceStatus.size == 1) + assert(aliceStatus.head.swapId == swapId) + + // swap in receiver (alice) confirms opening tx on-chain + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + + // swap in sender (bob) confirms opening tx on-chain before publishing claim-by-coop tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + + // swap in sender (bob) confirms claim-by-coop tx published and confirmed on-chain + val claimTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCoopBlock, 0, claimTx) + + // swap in receiver (alice) confirms opening tx spent by claim tx + alice.watcher.expectMsgType[WatchOutputSpent].replyTo ! WatchOutputSpentTriggered(claimTx) + + // swap in receiver (alice) completed swap with coop cancel message to sender (bob) + val claimByCoopEvent = aliceSwap.swapEvents.expectMsgType[ClaimByCoopOffered] + assert(claimByCoopEvent.swapId == swapId) + + // swap in sender (bob) confirms completed swap with claim-by-coop tx + assert(bobSwap.swapEvents.expectMsgType[ClaimByCoopConfirmed].swapId == swapId) + } + + test("swap in - claim by csv, receiver does not pay after opening tx confirmed") { f => + import f._ + + val (aliceSwap, bobSwap) = swapActors(alice, bob) + val shortChannelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByCsvBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx published + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount + premium) + + // swap in receiver (alice) stops unexpectedly + aliceSwap.swapRegister.toClassic ! Kill + + // bob has status of 1 pending swap + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // opening tx buried by csv delay + bob.watcher.expectMsgType[WatchFundingDeeplyBuried].replyTo ! WatchFundingDeeplyBuriedTriggered(claimByCsvBlock, 0, openingTx) + + // swap in sender (bob) confirms claim-by-csv tx published and confirmed if Alice does not send payment + val claimTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCsvBlock, 0, claimTx) + + // swap in sender (bob) confirms claim-by-csv + assert(bobSwap.swapEvents.expectMsgType[ClaimByCsvConfirmed].swapId == swapId) + } + + test("swap in - claim by coop, receiver cancels while waiting for opening tx to confirm") { f => + import f._ + + val (aliceSwap, bobSwap) = swapActors(alice, bob) + val shortChannelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val premium = (feeRatePerKw * claimByInvoiceTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByCoopBlock = claimByCsvDelta.toCltvExpiry(openingBlock).blockHeight + bob.wallet.confirmedBalance = amount + premium + + // swap in sender (bob) requests a swap in with swap in receiver (alice) + bobSwap.swapRegister ! SwapInRequested(bobSwap.cli.ref.toTyped, amount, shortChannelId) + val swapId = bobSwap.cli.expectMsgType[SwapOpened].swapId + + // swap in sender (bob) confirms opening tx is published, but NOT yet confirmed on-chain + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + + // swap in receiver (alice) sends CoopClose before the opening tx has been confirmed on-chain + aliceSwap.swapRegister ! CancelSwapRequested(aliceSwap.cli.ref.toTyped, swapId) + val claimByCoopEvent = aliceSwap.swapEvents.expectMsgType[ClaimByCoopOffered] + assert(claimByCoopEvent.swapId == swapId) + + // swap in sender (bob) watches for opening tx to be confirmed in a block before publishing the claim by coop tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + + // swap in sender (bob) confirms claim by coop tx published and confirmed on-chain + val claimByCoopTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + bob.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByCoopBlock, 0, claimByCoopTx) + + // swap in sender (bob) confirms claim-by-coop + assert(bobSwap.swapEvents.expectMsgType[ClaimByCoopConfirmed].swapId == swapId) + } + + test("swap out - claim by invoice") { f => + import f._ + + val (aliceSwap, bobSwap) = swapActors(alice, bob) + val shortChannelId = connectNodes(alice, bob) + + // bob must have enough on-chain balance to send + val amount = Satoshi(1000) + val feeRatePerKw = alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val fee = (feeRatePerKw * openingTxWeight / 1000).toLong.sat + val openingBlock = BlockHeight(1) + val claimByInvoiceBlock = BlockHeight(4) + bob.wallet.confirmedBalance = amount + fee + + // swap out receiver (alice) requests a swap out with swap out sender (bob) + aliceSwap.swapRegister ! SwapOutRequested(aliceSwap.cli.ref.toTyped, amount, shortChannelId) + val swapId = aliceSwap.cli.expectMsgType[SwapOpened].swapId + + // swap out receiver (alice) sends a payment of `fee` to swap out sender (bob) + assert(aliceSwap.paymentEvents.expectMsgType[PaymentSent].recipientAmount === toMilliSatoshi(fee)) + assert(bobSwap.paymentEvents.expectMsgType[PaymentReceived].amount === toMilliSatoshi(fee)) + + // swap out sender (bob) confirms opening tx published + val openingTx = bobSwap.swapEvents.expectMsgType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount) + + // bob has status of 1 pending swap + bobSwap.swapRegister ! ListPendingSwaps(bobSwap.cli.ref.toTyped) + val bobStatus = bobSwap.cli.expectMsgType[Iterable[Status]] + assert(bobStatus.size == 1) + assert(bobStatus.head.swapId === swapId) + + // swap out receiver (alice) confirms opening tx on-chain + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(openingBlock, 0, openingTx) + assert(openingTx.txOut.head.amount == amount) + + // swap out receiver (alice) sends a payment of `amount` to swap out sender (bob) + assert(aliceSwap.paymentEvents.expectMsgType[PaymentSent].recipientAmount === toMilliSatoshi(amount)) + assert(bobSwap.paymentEvents.expectMsgType[PaymentReceived].amount === toMilliSatoshi(amount)) + + // swap out receiver (alice) confirms claim-by-invoice tx published + val claimTx = aliceSwap.swapEvents.expectMsgType[TransactionPublished].tx + alice.watcher.expectMsgType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(claimByInvoiceBlock, 0, claimTx) + + // both parties publish that the swap was completed via claim-by-invoice + assert(aliceSwap.swapEvents.expectMsgType[ClaimByInvoiceConfirmed].swapId == swapId) + assert(bobSwap.swapEvents.expectMsgType[ClaimByInvoicePaid].swapId == swapId) + } + + test("eclair forwards swap messages to the SwapRegister") { f => + + + val protocolVersion = 2 + val swapId = hex"dd650741ee45fbad5df209bfb5aea9537e2e6d946cc7ece3b4492bbae0732634" + val premium = 10 + val responderPubkey = randomKey().publicKey + + val swapInAgreement = SwapInAgreement(protocolVersion, swapId.toHex, responderPubkey.toString, premium) + + // TODO: add message to SwapRegister which forwards messages to channel peer + // alice.peer.send(peer, swapInAgreement) + //val messageReceived = alice.swapRegister.expectMsgType[MessageReceived] + //assert(messageReceived.message === swapInAgreement) + } + +} diff --git a/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..e9b29e86aa --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutReceiverSpec.scala @@ -0,0 +1,148 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentReceived} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoicePaid, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.openingTxWeight +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapOutAgreementCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.SwapOutRequest +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import java.sql.DriverManager +import scala.concurrent.duration._ + +// with BitcoindService +case class SwapOutReceiverSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val openingFee: Long = (feeRatePerKw * openingTxWeight / 1000).toLong // TODO: how should swap out initiator calculate an acceptable swap opening tx fee? + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val makerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + val takerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val makerPubkey: PublicKey = makerPrivkey.publicKey + val takerPubkey: PublicKey = takerPrivkey.publicKey + val paymentPreimage: ByteVector32 = ByteVector32.One + val feePreimage: ByteVector32 = ByteVector32.Zeroes + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapOutRequest = SwapOutRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, takerPubkey.toHex) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + + val wallet = new DummyOnChainWallet() + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + + // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapInSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapMaker(TestConstants.Alice.nodeParams, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-out-receiver") + + withFixture(test.toNoArgTest(FixtureParam(swapInSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapInSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path for new swap out receiver") { f => + import f._ + + // start new SwapInSender + swapInSender ! StartSwapOutReceiver(request) + monitor.expectMessage(StartSwapOutReceiver(request)) + + // SwapInSender:SwapOutAgreement -> SwapInReceiver + val agreement = swapOutAgreementCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + assert(agreement.pubkey == makerPubkey.toHex) + + // SwapInReceiver pays the fee invoice + val feeInvoice = Bolt11Invoice.fromString(agreement.payreq).get + val feeReceived = PaymentReceived(feeInvoice.paymentHash, Seq(PaymentReceived.PartialPayment(openingFee.sat.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + swapEvents.expectNoMessage() + testKit.system.eventStream ! Publish(feeReceived) + + // SwapInSender publishes opening tx on-chain + val openingTx = swapEvents.expectMessageType[TransactionPublished].tx + assert(openingTx.txOut.head.amount == amount) + + // SwapInSender:OpeningTxBroadcasted -> SwapInReceiver + val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + val paymentInvoice = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get + + // wait for SwapInSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapInSender reports status of awaiting payment + swapInSender ! GetStatus(userCli.ref) + assert(userCli.expectMessageType[SwapStatus].behavior == "awaitClaimPayment") + + // SwapInSender receives a payment with the corresponding payment hash + // TODO: convert from ShortChannelId to ByteVector32 + val paymentReceived = PaymentReceived(paymentInvoice.paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapInSender reports a successful coop close + swapEvents.expectMessageType[ClaimByInvoicePaid] + + // wait for swap actor to stop + testKit.stop(swapInSender) + } +} 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..00e1ba1a53 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapOutSenderSpec.scala @@ -0,0 +1,183 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap + +import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe} +import akka.actor.typed.ActorRef +import akka.actor.typed.eventstream.EventStream.{Publish, Subscribe} +import akka.actor.typed.scaladsl.Behaviors +import akka.actor.typed.scaladsl.adapter._ +import akka.util.Timeout +import com.typesafe.config.ConfigFactory +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong, Transaction} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchTxConfirmedTriggered +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode +import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSent} +import fr.acinq.eclair.plugins.peerswap.SwapCommands._ +import fr.acinq.eclair.plugins.peerswap.SwapEvents.{ClaimByInvoiceConfirmed, SwapEvent, TransactionPublished} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Status, SwapStatus} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.swapOutRequestCodec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.{OpeningTxBroadcasted, SwapOutAgreement, SwapOutRequest} +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import grizzled.slf4j.Logging +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{BeforeAndAfterAll, Outcome} + +import java.sql.DriverManager +import java.util.UUID +import scala.concurrent.duration._ + +// with BitcoindService +case class SwapOutSenderSpec() extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike with BeforeAndAfterAll with Logging { + override implicit val timeout: Timeout = Timeout(30 seconds) + val protocolVersion = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Bob.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val fee: Satoshi = 100 sat + val swapId: String = ByteVector32.Zeroes.toHex + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + val makerPrivkey: PrivateKey = PrivateKey(randomBytes32()) + val takerPrivkey: PrivateKey = keyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val makerPubkey: PublicKey = makerPrivkey.publicKey + val takerPubkey: PublicKey = takerPrivkey.publicKey + val feeRatePerKw: FeeratePerKw = TestConstants.Bob.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Bob.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val paymentPreimage: ByteVector32 = ByteVector32.One + val feePreimage: ByteVector32 = ByteVector32.Zeroes + val paymentInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey, Left("SwapOutSender payment invoice"), CltvExpiryDelta(18)) + val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(feePreimage), makerPrivkey, Left("SwapOutSender fee invoice"), CltvExpiryDelta(18)) + val otherInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), randomBytes32(), makerPrivkey, Left("SwapOutSender other invoice"), CltvExpiryDelta(18)) + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val request: SwapOutRequest = SwapOutRequest(protocolVersion, swapId, noAsset, network, shortChannelId.toString, amount.toLong, makerPubkey.toHex) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + override def withFixture(test: OneArgTest): Outcome = { + val watcher = testKit.createTestProbe[ZmqWatcher.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val register = testKit.createTestProbe[Any]() + val relayer = testKit.createTestProbe[Any]() + val router = testKit.createTestProbe[Any]() + val switchboard = testKit.createTestProbe[Any]() + val paymentInitiator = testKit.createTestProbe[Any]() + + val wallet = new DummyOnChainWallet() + val userCli = testKit.createTestProbe[Status]() + val sender = testKit.createTestProbe[Any]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val monitor = testKit.createTestProbe[SwapCommands.SwapCommand]() + val keyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + + // subscribe to notification events from SwapInReceiver when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + val swapOutSender = testKit.spawn(Behaviors.monitor(monitor.ref, SwapTaker(TestConstants.Bob.nodeParams, paymentInitiator.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, keyManager, db)), "swap-out-sender") + + withFixture(test.toNoArgTest(FixtureParam(swapOutSender, userCli, monitor, register, relayer, router, paymentInitiator, switchboard, paymentHandler, sender, TestConstants.Bob.nodeParams, watcher, wallet, swapEvents))) + } + + case class FixtureParam(swapOutSender: ActorRef[SwapCommands.SwapCommand], userCli: TestProbe[Status], monitor: TestProbe[SwapCommands.SwapCommand], register: TestProbe[Any], relayer: TestProbe[Any], router: TestProbe[Any], paymentInitiator: TestProbe[Any], switchboard: TestProbe[Any], paymentHandler: TestProbe[Any], sender: TestProbe[Any], nodeParams: NodeParams, watcher: TestProbe[ZmqWatcher.Command], wallet: OnChainWallet, swapEvents: TestProbe[SwapEvent]) + + test("happy path for new swap out sender") { f => + import f._ + + // start new SwapOutSender + swapOutSender ! StartSwapOutSender(amount, swapId, shortChannelId) + monitor.expectMessageType[StartSwapOutSender] + + // SwapOutSender: SwapOutRequest -> SwapOutReceiver + val request = swapOutRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + assert(request.pubkey == takerPubkey.toHex) + + // SwapOutReceiver: SwapOutAgreement -> SwapOutSender (request fee) + swapOutSender ! SwapMessageReceived(SwapOutAgreement(request.protocolVersion, request.swapId, makerPubkey.toString(), feeInvoice.toString)) + monitor.expectMessageType[SwapMessageReceived] + + // SwapOutSender validates fee invoice before paying the invoice + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(feeInvoice.amount_opt.get, feeInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + swapOutSender ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") + + // wait for SwapOutSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapOutSender confirms the fee invoice has been paid + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), feeInvoice.paymentHash, feePreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), fee.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val feePaymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(feePaymentEvent.isInstanceOf[PaymentSent] && feePaymentEvent.paymentHash === feeInvoice.paymentHash) + + // SwapOutSender reports status of awaiting opening transaction after paying claim invoice + swapOutSender ! GetStatus(userCli.ref) + monitor.expectMessageType[GetStatus] + assert(userCli.expectMessageType[SwapStatus].behavior == "payFeeInvoice") + + // SwapOutReceiver:OpeningTxBroadcasted -> SwapOutSender + val openingTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice.toString, txid, scriptOut, blindingKey) + swapOutSender ! SwapMessageReceived(openingTxBroadcasted) + monitor.expectMessageType[SwapMessageReceived] + + // ZmqWatcher -> SwapOutSender, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(request.amount.sat, makerPubkey, takerPubkey, paymentInvoice.paymentHash)), 0) + swapOutSender ! OpeningTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx)) + monitor.expectMessageType[OpeningTxConfirmed] + + // SwapOutSender validates invoice and opening transaction before paying the invoice + monitor.expectMessageType[ValidInvoice] + assert(paymentInitiator.expectMessageType[SendPaymentToNode] === SendPaymentToNode(paymentInvoice.amount_opt.get, paymentInvoice, nodeParams.maxPaymentAttempts, Some(swapId), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true)) + + // wait for SwapOutSender to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // SwapOutSender ignores payments that do not correspond to the invoice from SwapOutReceiver + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), ByteVector32.Zeroes, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + monitor.expectMessageType[PaymentEventReceived].paymentEvent + monitor.expectNoMessage() + + // SwapOutSender successfully pays the invoice from SwapOutReceiver and then commits a claim-by-invoice transaction + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), paymentInvoice.paymentHash, paymentPreimage, amount.toMilliSatoshi, makerNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + val paymentEvent = monitor.expectMessageType[PaymentEventReceived].paymentEvent + assert(paymentEvent.isInstanceOf[PaymentSent] && paymentEvent.paymentHash === paymentInvoice.paymentHash) + monitor.expectMessage(ClaimTxCommitted) + + // SwapOutSender reports a successful claim by invoice + swapEvents.expectMessageType[TransactionPublished] + val claimByInvoiceTx = makeSwapClaimByInvoiceTx(request.amount.sat, makerPubkey, takerPrivkey, paymentPreimage, feeRatePerKw, openingTx.txid, openingTxBroadcasted.scriptOut.toInt) + swapOutSender ! ClaimTxConfirmed(WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx)) + monitor.expectMessageType[ClaimTxConfirmed] + swapEvents.expectMessageType[ClaimByInvoiceConfirmed] + + val deathWatcher = testKit.createTestProbe[Any]() + deathWatcher.expectTerminated(swapOutSender) + } +} 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..bbfad86598 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/SwapRegisterSpec.scala @@ -0,0 +1,253 @@ +/* + * 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, Transaction} +import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchTxConfirmed, WatchTxConfirmedTriggered} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.channel.DATA_NORMAL +import fr.acinq.eclair.channel.Register.ForwardShortId +import fr.acinq.eclair.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.{SwapInRequested, SwapOutRequested, SwapTerminated, WrappedUnknownMessageReceived} +import fr.acinq.eclair.plugins.peerswap.SwapResponses.{Response, SwapExistsForChannel, SwapOpened} +import fr.acinq.eclair.plugins.peerswap.db.sqlite.SqliteSwapsDb +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions.{makeSwapClaimByInvoiceTx, makeSwapOpeningTxOut} +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.{openingTxBroadcastedCodec, swapInRequestCodec} +import fr.acinq.eclair.plugins.peerswap.wire.protocol._ +import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec +import fr.acinq.eclair.wire.protocol.UnknownMessage +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, NodeParams, ShortChannelId, TestConstants, TimestampMilli, ToMilliSatoshiConversion, randomBytes32} +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, Outcome, ParallelTestExecution} +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 = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val fee: Satoshi = 22 sat + val channelData: DATA_NORMAL = ChannelCodecsSpec.normal + val shortChannelId: ShortChannelId = channelData.shortIds.real.toOption.get + val channelId: ByteVector32 = channelData.channelId + val bobPayoutPubkey: PublicKey = PublicKey(hex"0270685ca81a8e4d4d01beec5781f4cc924684072ae52c507f8ebe9daf0caaab7b") + val premium = 10 + val scriptOut = 0 + val blindingKey = "" + val txId: String = ByteVector32.One.toHex + val aliceKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val aliceDb = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + val bobKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + val bobDb = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + val aliceNodeId: PublicKey = TestConstants.Alice.nodeParams.nodeId + val feeInvoice: Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(paymentPreimage(1)), bobPrivkey(swapId(1)), Left("PeerSwap fee invoice 1"), CltvExpiryDelta(18)) + val feeRatePerKw: FeeratePerKw = TestConstants.Alice.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = TestConstants.Alice.nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget) + val swapInRequest: SwapInRequest = SwapInRequest(protocolVersion, swapId(0), noAsset, network, shortChannelId.toString, amount.toLong, alicePubkey(swapId(0)).toString()) + val swapInAgreement: SwapInAgreement = SwapInAgreement(protocolVersion, swapId(0), bobPubkey(swapId(0)).toString(), premium) + val swapOutRequest: SwapOutRequest = SwapOutRequest(protocolVersion, swapId(1), noAsset, network, shortChannelId.toString, amount.toLong, bobPubkey(swapId(1)).toString()) + val swapOutAgreement: SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId(1), bobPubkey(swapId(1)).toString(), feeInvoice.toString) + + def paymentPreimage(index: Int): ByteVector32 = index match { + case 0 => ByteVector32.Zeroes + case 1 => ByteVector32.One + case _ => randomBytes32() + } + def privKey(index: Int): PrivateKey = index match { + case 0 => alicePrivkey(swapId(0)) + case _ => bobPrivkey(swapId(index)) + } + def swapId(index: Int): String = paymentPreimage(index).toHex + def alicePrivkey(swapId: String): PrivateKey = aliceKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def alicePubkey(swapId: String): PublicKey = alicePrivkey(swapId).publicKey + def bobPrivkey(swapId: String): PrivateKey = bobKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def bobPubkey(swapId: String): PublicKey = bobPrivkey(swapId).publicKey + def invoice(index: Int): Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage(index)), privKey(index), Left(s"PeerSwap payment invoice $index"), CltvExpiryDelta(18)) + def openingTxBroadcasted(index: Int): OpeningTxBroadcasted = OpeningTxBroadcasted(swapId(index), invoice(index).toString, txId, scriptOut, blindingKey) + def makePluginMessage(message: HasSwapId): WrappedUnknownMessageReceived = WrappedUnknownMessageReceived(UnknownMessageReceived(null, alicePubkey(""), makeUnknownMessage(message), null)) + def expectUnknownMessage(register: TestProbe[Any]): UnknownMessage = register.expectMessageType[ForwardShortId[UnknownMessage]].message + + override def withFixture(test: OneArgTest): Outcome = { + val userCli = testKit.createTestProbe[Response]() + val swapEvents = testKit.createTestProbe[SwapEvent]() + val register = testKit.createTestProbe[Any]() + val monitor = testKit.createTestProbe[SwapRegister.Command]() + val paymentHandler = testKit.createTestProbe[Any]() + val wallet = new DummyOnChainWallet() { + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(6930 sat, 0 sat)) + } + val watcher = testKit.createTestProbe[Any]() + + // subscribe to notification events from SwapInSender when a payment is successfully received or claimed via coop or csv + testKit.system.eventStream ! Subscribe[SwapEvent](swapEvents.ref) + + withFixture(test.toNoArgTest(FixtureParam(userCli, swapEvents, register, monitor, paymentHandler, wallet, watcher))) + } + + case class FixtureParam(userCli: TestProbe[Response], swapEvents: TestProbe[SwapEvent], register: TestProbe[Any], monitor: TestProbe[SwapRegister.Command], paymentHandler: TestProbe[Any], wallet: OnChainWallet, watcher: TestProbe[Any]) + + test("restore the swap register from the database") { f => + import f._ + + val savedData: Set[SwapData] = Set(SwapData(swapInRequest, swapInAgreement, invoice(0), openingTxBroadcasted(0), swapRole = SwapRole.Maker, isInitiator = true), + SwapData(swapOutRequest, swapOutAgreement, invoice(1), openingTxBroadcasted(1), swapRole = SwapRole.Taker, isInitiator = true)) + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, savedData)), "SwapRegister") + + // wait for SwapMaker and SwapTaker to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // swapId0 - Taker: payment(paymentHash) -> Maker + val paymentHash0 = Bolt11Invoice.fromString(openingTxBroadcasted(0).payreq).get.paymentHash + val paymentReceived0 = PaymentReceived(paymentHash0, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived0) + + // swapId0 - SwapRegister received notice that SwapInSender swap completed + val swap0Completed = swapEvents.expectMessageType[ClaimByInvoicePaid] + assert(swap0Completed.swapId === swapId(0)) + + // swapId0: SwapRegister receives notification that the swap Maker actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(0)) + + // swapId1 - ZmqWatcher -> Taker, trigger confirmation of opening transaction + val openingTx = Transaction(2, Seq(), Seq(makeSwapOpeningTxOut(swapOutRequest.amount.sat, bobPubkey(swapId(1)), alicePubkey(swapId(1)), invoice(1).paymentHash)), 0) + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(1), 0, openingTx) + + // swapId1 - wait for Taker to subscribe to PaymentEventReceived messages + swapEvents.expectNoMessage() + + // swapId1 - Taker validates the invoice and opening transaction before paying the invoice + testKit.system.eventStream ! Publish(PaymentSent(UUID.randomUUID(), invoice(1).paymentHash, paymentPreimage(1), amount.toMilliSatoshi, aliceNodeId, PaymentSent.PartialPayment(UUID.randomUUID(), amount.toMilliSatoshi, 0.sat.toMilliSatoshi, channelId, None) :: Nil)) + + // swapId1 - ZmqWatcher -> Taker, trigger confirmation of claim-by-invoice transaction + val claimByInvoiceTx = makeSwapClaimByInvoiceTx(swapOutRequest.amount.sat, bobPubkey(swapId(1)), alicePrivkey(swapId(1)), paymentPreimage(1), feeRatePerKw, openingTx.txid, 0) + watcher.expectMessageType[WatchTxConfirmed].replyTo ! WatchTxConfirmedTriggered(BlockHeight(6), 0, claimByInvoiceTx) + + // swapId1 - SwapRegister received notice that SwapOutSender completed + swapEvents.expectMessageType[TransactionPublished] + assert(swapEvents.expectMessageType[ClaimByInvoiceConfirmed].swapId === swapId(1)) + + // swapId1 - SwapRegister receives notification that the swap Taker actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId(1)) + + testKit.stop(swapRegister) + } + + test("register a new swap in the swap register") { f => + import f._ + + // initialize SwapRegister + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") + swapEvents.expectNoMessage() + userCli.expectNoMessage() + + // User:SwapInRequested -> SwapInRegister + swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + val swapId = userCli.expectMessageType[SwapOpened].swapId + monitor.expectMessageType[SwapInRequested] + + // Alice:SwapInRequest -> Bob + val swapInRequest = swapInRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + assert(swapId === swapInRequest.swapId) + + // Alice's database has no items before the opening tx is published + assert(aliceDb.list().isEmpty) + + // Bob: SwapInAgreement -> Alice + swapRegister ! makePluginMessage(SwapInAgreement(swapInRequest.protocolVersion, swapInRequest.swapId, bobPayoutPubkey.toString(), premium)) + monitor.expectMessageType[WrappedUnknownMessageReceived] + + // Alice's database should be updated before the opening tx is published + eventually(PatienceConfiguration.Timeout(2 seconds), PatienceConfiguration.Interval(1 second)) { + assert(aliceDb.list().size == 1) + } + + // SwapInSender confirms opening tx published + swapEvents.expectMessageType[TransactionPublished] + + // Alice:OpeningTxBroadcasted -> Bob + val openingTxBroadcasted = openingTxBroadcastedCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + + // Bob: payment(paymentHash) -> Alice + val paymentHash = Bolt11Invoice.fromString(openingTxBroadcasted.payreq).get.paymentHash + val paymentReceived = PaymentReceived(paymentHash, Seq(PaymentReceived.PartialPayment(amount.toMilliSatoshi, channelId, TimestampMilli(1553784963659L)))) + testKit.system.eventStream ! Publish(paymentReceived) + + // SwapRegister received notice that SwapInSender completed + assert(swapEvents.expectMessageType[ClaimByInvoicePaid].swapId === swapId) + + // SwapRegister receives notification that the swap actor stopped + assert(monitor.expectMessageType[SwapTerminated].swapId === swapId) + + testKit.stop(swapRegister) + } + + test("fail second swap request on same channel") { f => + import f._ + + // initialize SwapRegister + val swapRegister = testKit.spawn(Behaviors.monitor(monitor.ref, SwapRegister(TestConstants.Alice.nodeParams, paymentHandler.ref.toClassic, watcher.ref, register.ref.toClassic, wallet, aliceKeyManager, aliceDb, Set())), "SwapRegister") + swapEvents.expectNoMessage() + userCli.expectNoMessage() + + // first swap request succeeds + swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + val response = userCli.expectMessageType[SwapOpened] + val request = swapInRequestCodec.decode(expectUnknownMessage(register).data.drop(2).toBitVector).require.value + assert(response.swapId === request.swapId) + + // subsequent swap requests with same channel id from the user or peer should fail + swapRegister ! SwapInRequested(userCli.ref, amount, shortChannelId) + userCli.expectMessageType[SwapExistsForChannel] + register.expectNoMessage() + + swapRegister ! SwapOutRequested(userCli.ref, amount, shortChannelId) + userCli.expectMessageType[SwapExistsForChannel] + register.expectNoMessage() + + swapRegister ! makePluginMessage(swapInRequest) + register.expectNoMessage() + + swapRegister ! makePluginMessage(swapOutRequest) + register.expectNoMessage() + } + + test("list the active swap in the register") { f => + + + + } +} 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..1ff74692a9 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/db/SwapsDbSpec.scala @@ -0,0 +1,118 @@ +/* + * 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 = 2 + val noAsset = "" + val network: String = NodeParams.chainFromHash(TestConstants.Alice.nodeParams.chainHash) + val amount: Satoshi = 1000 sat + val fee: Satoshi = 100 sat + val makerKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Alice.seed, TestConstants.Alice.nodeParams.chainHash) + val takerKeyManager: SwapKeyManager = new LocalSwapKeyManager(TestConstants.Bob.seed, TestConstants.Bob.nodeParams.chainHash) + val makerNodeId: PublicKey = PrivateKey(randomBytes32()).publicKey + val premium = 10 + val txid: String = ByteVector32.One.toHex + val scriptOut: Long = 0 + val blindingKey: String = "" + val paymentPreimage: ByteVector32 = ByteVector32.One + val feePreimage: ByteVector32 = ByteVector32.Zeroes + val scid = "1x1x1" + + def paymentInvoice(swapId: String): Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(amount.toMilliSatoshi), Crypto.sha256(paymentPreimage), makerPrivkey(swapId), Left("SwapOutSender payment invoice"), CltvExpiryDelta(18)) + def feeInvoice(swapId: String): Bolt11Invoice = Bolt11Invoice(TestConstants.Alice.nodeParams.chainHash, Some(fee.toMilliSatoshi), Crypto.sha256(feePreimage), makerPrivkey(swapId), Left("SwapOutSender fee invoice"), CltvExpiryDelta(18)) + def makerPrivkey(swapId: String): PrivateKey = makerKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def takerPrivkey(swapId: String): PrivateKey = takerKeyManager.openingPrivateKey(SwapKeyManager.keyPath(swapId)).privateKey + def makerPubkey(swapId: String): PublicKey = makerPrivkey(swapId).publicKey + def takerPubkey(swapId: String): PublicKey = takerPrivkey(swapId).publicKey + def swapInRequest(swapId: String): SwapInRequest = SwapInRequest(protocolVersion = protocolVersion, swapId = swapId, asset = noAsset, network = network, scid = scid, amount = amount.toLong, pubkey = makerPubkey(swapId).toHex) + def swapOutRequest(swapId: String): SwapOutRequest = SwapOutRequest(protocolVersion = protocolVersion, swapId = swapId, asset = noAsset, network = network, scid = scid, amount = amount.toLong, pubkey = takerPubkey(swapId).toHex) + def swapInAgreement(swapId: String): SwapInAgreement = SwapInAgreement(protocolVersion, swapId, takerPubkey(swapId).toHex, premium) + def swapOutAgreement(swapId: String): SwapOutAgreement = SwapOutAgreement(protocolVersion, swapId, makerPubkey(swapId).toHex, feeInvoice(swapId).toString) + def openingTxBroadcasted(swapId: String): OpeningTxBroadcasted = OpeningTxBroadcasted(swapId, paymentInvoice(swapId).toString, txid, scriptOut, blindingKey) + def paymentCompleteResult(swapId: String): ClaimByInvoicePaid = ClaimByInvoicePaid(swapId, PaymentReceived(paymentInvoice(swapId).paymentHash, Seq(PartialPayment(amount.toMilliSatoshi, randomBytes32())))) + def swapData(swapId: String, isInitiator: Boolean, swapType: SwapRole): SwapData = { + val (request, agreement) = (isInitiator, swapType == Maker) match { + case (true, true) => (swapInRequest(swapId), swapInAgreement(swapId)) + case (false, false) => (swapInRequest(swapId), swapInAgreement(swapId)) + case (true, false) => (swapOutRequest(swapId), swapOutAgreement(swapId)) + case (false, true) => (swapOutRequest(swapId), swapOutAgreement(swapId)) + } + SwapData(request, agreement, paymentInvoice(swapId), openingTxBroadcasted(swapId), swapType, isInitiator) + } + + test("init database two times in a row") { + val connection = DriverManager.getConnection("jdbc:sqlite::memory:") + new SqliteSwapsDb(connection) + new SqliteSwapsDb(connection) + } + + test("add/list/addResult/restore/remove swaps") { + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + assert(db.list().isEmpty) + + val swap_1 = swapData(randomBytes32().toString(),isInitiator = true, Maker) + val swap_2 = swapData(randomBytes32().toString(),isInitiator = false, Maker) + val swap_3 = swapData(randomBytes32().toString(),isInitiator = true, Taker) + val swap_4 = swapData(randomBytes32().toString(),isInitiator = false, Taker) + + assert(db.list().toSet == Set.empty) + db.add(swap_1) + assert(db.list().toSet == Set(swap_1)) + db.add(swap_1) // duplicate is ignored + assert(db.list().size == 1) + db.add(swap_2) + db.add(swap_3) + db.add(swap_4) + assert(db.list().toSet == Set(swap_1, swap_2, swap_3, swap_4)) + db.addResult(paymentCompleteResult(swap_2.request.swapId)) + assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) + db.remove(swap_2.request.swapId) + assert(db.list().toSet == Set(swap_1, swap_3, swap_4)) + assert(db.restore().toSet == Set(swap_1, swap_3, swap_4)) + } + + test("concurrent swap updates") { + val db = new SqliteSwapsDb(DriverManager.getConnection("jdbc:sqlite::memory:")) + assert(db.list().isEmpty) + + implicit val ec: ExecutionContextExecutor = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(8)) + val futures = for (_ <- 0 until 2500) yield { + Future(db.add(swapData(randomBytes32().toString(),isInitiator = true, Maker))) + } + val res = Future.sequence(futures) + Await.result(res, 60 seconds) + } +} \ No newline at end of file diff --git a/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..f58b495225 --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/transactions/SwapTransactionsSpec.scala @@ -0,0 +1,102 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.transactions + +import akka.actor.typed.ActorRef +import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps} +import akka.pattern.pipe +import akka.testkit.TestProbe +import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong} +import fr.acinq.eclair._ +import fr.acinq.eclair.blockchain.DummyOnChainWallet +import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse +import fr.acinq.eclair.blockchain.bitcoind.BitcoindService +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.publish.FinalTxPublisher +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext +import fr.acinq.eclair.plugins.peerswap.transactions.SwapTransactions._ +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions.checkSpendable +import grizzled.slf4j.Logging +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuiteLike + +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global + +class SwapTransactionsSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with BeforeAndAfterAll with Logging { + val makerRefundPriv: PrivateKey = PrivateKey(randomBytes32()) + val takerPaymentPriv: PrivateKey = PrivateKey(randomBytes32()) + val paymentPreimage: ByteVector32 = randomBytes32() + val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage) + val amount: Satoshi = 30000 sat + val premium: Satoshi = 150 sat + val openingTxId: ByteVector32 = randomBytes32() + val openingTxOut: Int = 0 + val claimInput: Transactions.InputInfo = makeSwapOpeningInputInfo(openingTxId, openingTxOut, amount, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + + val csvDelay = 20 + val localDustLimit: Satoshi = Satoshi(546) + val feeratePerKw: FeeratePerKw = FeeratePerKw(10000 sat) + val wallet = new DummyOnChainWallet() + + + override def beforeAll(): Unit = { + startBitcoind() + waitForBitcoindReady() + } + + override def afterAll(): Unit = { + stopBitcoind() + } + + def createFixture(): Fixture = { + val probe = TestProbe() + val watcher = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + val publisher = system.spawnAnonymous(FinalTxPublisher(TestConstants.Alice.nodeParams, bitcoinClient, watcher.ref.toTyped, TxPublishContext(UUID.randomUUID(), randomKey().publicKey, None))) + Fixture(bitcoinClient, publisher, watcher, probe) + } + + case class Fixture(bitcoinClient: BitcoinCoreClient, publisher: ActorRef[FinalTxPublisher.Command], watcher: TestProbe, probe: TestProbe) + + test("check validity of PeerSwap claim transactions") { + val f = createFixture() + import f._ + + val swapTxOut = makeSwapOpeningTxOut(amount + premium, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + wallet.makeFundingTx(swapTxOut.publicKeyScript, amount + premium, feeratePerKw).pipeTo(probe.ref) + val response = probe.expectMsgType[MakeFundingTxResponse] + val openingTx = response.fundingTx + val openingTxOut = response.fundingTxOutputIndex + val inputInfo = makeSwapOpeningInputInfo(openingTx.txid, openingTxOut, amount + premium, makerRefundPriv.publicKey, takerPaymentPriv.publicKey, paymentHash) + + val swapClaimByInvoiceTx = makeSwapClaimByInvoiceTx(amount + premium, makerRefundPriv.publicKey, takerPaymentPriv, paymentPreimage, feeratePerKw, openingTx.txid, openingTxOut) + assert(swapClaimByInvoiceTx.txIn.head.sequence == 0) + assert(checkSpendable(SwapClaimByInvoiceTx(inputInfo, swapClaimByInvoiceTx)).isSuccess) + + val swapClaimByCoopTx = makeSwapClaimByCoopTx(amount + premium, makerRefundPriv, takerPaymentPriv, paymentHash, feeratePerKw, openingTx.txid, openingTxOut) + assert(swapClaimByCoopTx.txIn.head.sequence == 0) + assert(checkSpendable(SwapClaimByCoopTx(inputInfo, swapClaimByCoopTx)).isSuccess) + + val swapClaimByCsvTx = makeSwapClaimByCsvTx(amount + premium, makerRefundPriv, takerPaymentPriv.publicKey, paymentHash, feeratePerKw, openingTx.txid, openingTxOut) + assert(swapClaimByCsvTx.txIn.head.sequence == 1008) + assert(checkSpendable(SwapClaimByCsvTx(inputInfo, swapClaimByCsvTx)).isSuccess) + } +} \ No newline at end of file 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..15c7eb8bef --- /dev/null +++ b/plugins/peerswap/src/test/scala/fr/acinq/eclair/plugins/peerswap/wire/protocol/PeerSwapMessageCodecsSpec.scala @@ -0,0 +1,116 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.plugins.peerswap.wire.protocol + +import fr.acinq.eclair.plugins.peerswap.PeerSwapSpec +import fr.acinq.eclair.plugins.peerswap.wire.protocol.PeerSwapMessageCodecs.peerSwapMessageCodecWithFallback +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"0xa4557b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + val obj = SwapInRequest(protocolVersion, swapId.toHex, asset, network, shortId.toString, amount, pubkey.toString()) + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + 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"a4577b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226173736574223a22222c226e6574776f726b223a2272656774657374222c2273636964223a22353339323638783834357831222c22616d6f756e74223a31303030302c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866227d" + 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"a4597b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c227072656d69756d223a313030307d" + 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"a45b7b2270726f746f636f6c5f76657273696f6e223a322c22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c227075626b6579223a22303331623834633535363762313236343430393935643365643561616261303536356437316531383334363034383139666639633137663565396435646430373866222c22706179726571223a22696e766f6963652068657265227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode OpeningTxBroadcasted messages to/from binary") { + val json = s"""{"swap_id":"${swapId.toHex}","payreq":"$payreq","tx_id":"$txid","script_out":$scriptOut,"blinding_key":"$blindingKey"}""".stripMargin + val obj = OpeningTxBroadcasted(swapId = swapId.toHex, payreq = payreq, txId = txid, scriptOut = scriptOut, blindingKey = blindingKey) + val bin = hex"a45d7b22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c22706179726571223a22696e766f6963652068657265222c2274785f6964223a2233386238353463353639666634623862323565366565656333316432316365346131656536646263326166633765666462343463383164353133623462666663222c227363726970745f6f7574223a302c22626c696e64696e675f6b6579223a22227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode Cancel messages to/from binary") { + val json = s"""{"swap_id":"${swapId.toHex}","message":"$message"}""".stripMargin + val obj = CancelSwap(swapId = swapId.toHex, message = message) + val bin = hex"a45f7b22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226d657373616765223a2261206d657373616765227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + + test("encode/decode CoopClose messages to/from binary") { + val json = s"""{"swap_id":"${swapId.toHex}","message":"$message","privkey":"$privkey"}""".stripMargin + val obj = CoopClose(swapId = swapId.toHex, message = message, privkey = privkey.toString) + val bin = hex"a4617b22737761705f6964223a2264643635303734316565343566626164356466323039626662356165613935333765326536643934366363376563653362343439326262616530373332363334222c226d657373616765223a2261206d657373616765222c22707269766b6579223a223c707269766174655f6b65793e227d" + val encoded = peerSwapMessageCodecWithFallback.encode(obj).require + val decoded = peerSwapMessageCodecWithFallback.decode(encoded).require + val decoded_bin = peerSwapMessageCodecWithFallback.decode(bin.bits).require + assert(json === obj.json) + assert(encoded.bytes === bin) + assert(obj === decoded.value) + assert(obj === decoded_bin.value) + } + +} diff --git a/pom.xml b/pom.xml index ce714f5b7b..5be9073ac0 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ eclair-core eclair-front eclair-node + plugins/peerswap A scala implementation of the Lightning Network