Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Peerswap plugin #5

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions peerswap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Peerswap plugin

This plugin allows implements the PeerSwap protocol: https://github.com/ElementsProject/peerswap-spec/blob/main/peer-protocol.md

Disclaimer: PeerSwap is beta-grade software.

We currently only recommend using PeerSwap with small balances or on signet/testnet

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

## Build

To build this plugin, run the following command in this directory:

```sh
mvn package
```

## Run

To run eclair with this plugin, start eclair with the following command:

```sh
eclair-node-<version>/bin/eclair-node.sh <path-to-plugin-jar>/peerswap-plugin-<version>.jar
```

## Commands

```sh
eclair-cli swapin --shortChannelId=<short-channel-id>> --amountSat=<amount>
eclair-cli swapout --shortChannelId=<short-channel-id>> --amountSat=<amount>
eclair-cli listswaps
eclair-cli swaphistory
eclair-cli cancelswap --swapId=<swap-id>
```

## Persistence

This plugin stores its data into a sqlite database named `peer-swap.sqlite`.
It uses that database to ensure swaps are correctly executed even after a restart of the node.
You can check the status of pending swap by reading directly from that database or using the command `listwaps`.

## Seed

The seed used to generate keys for swaps is stored in the `swap_seed.dat` file. This seed should be backed up and always kept secret.


84 changes: 84 additions & 0 deletions peerswap/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>fr.acinq.eclair</groupId>
<artifactId>eclair-plugins_2.13</artifactId>
<version>0.8.1-SNAPSHOT</version>
</parent>

<artifactId>peerswap-plugin</artifactId>
<packaging>jar</packaging>
<name>peerswap-plugin</name>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Main-Class>fr.acinq.eclair.plugins.peerswap.PeerSwapPlugin</Main-Class>
</manifestEntries>
</transformer>
</transformers>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

<dependencies>
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>${scala.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>fr.acinq.eclair</groupId>
<artifactId>eclair-core_${scala.version.short}</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>fr.acinq.eclair</groupId>
<artifactId>eclair-node_${scala.version.short}</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<!-- TESTS -->
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-testkit_${scala.version.short}</artifactId>
<version>${akka.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor-testkit-typed_${scala.version.short}</artifactId>
<version>${akka.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>fr.acinq.eclair</groupId>
<artifactId>eclair-core_${scala.version.short}</artifactId>
<version>${project.version}</version>
<classifier>tests</classifier>
<type>test-jar</type>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.plugins.peerswap

import akka.http.scaladsl.common.{NameReceptacle, NameUnmarshallerReceptacle}
import akka.http.scaladsl.server.Route
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi}
import fr.acinq.eclair.api.directives.EclairDirectives
import fr.acinq.eclair.api.serde.FormParamExtractors._

object ApiHandlers {

import fr.acinq.eclair.api.serde.JsonSupport.{marshaller, serialization}
import fr.acinq.eclair.plugins.peerswap.ApiSerializers.formats

def registerRoutes(kit: PeerSwapKit, eclairDirectives: EclairDirectives): Route = {
import eclairDirectives._

val swapIdFormParam: NameUnmarshallerReceptacle[ByteVector32] = "swapId".as[ByteVector32](bytes32Unmarshaller)

val amountSatFormParam: NameReceptacle[Satoshi] = "amountSat".as[Satoshi]

val swapIn: Route = postRequest("swapin") { implicit t =>
formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) =>
complete(kit.swapIn(channelId, amount))
}
}

val swapOut: Route = postRequest("swapout") { implicit t =>
formFields(shortChannelIdFormParam, amountSatFormParam) { (channelId, amount) =>
complete(kit.swapOut(channelId, amount))
}
}

val listSwaps: Route = postRequest("listswaps") { implicit t =>
complete(kit.listSwaps())
}

val cancelSwap: Route = postRequest("cancelswap") { implicit t =>
formFields(swapIdFormParam) { swapId =>
complete(kit.cancelSwap(swapId.toString()))
}
}

val swapHistory: Route = postRequest("swaphistory") { implicit t =>
complete(kit.swapHistory())
}

val peerSwapRoutes: Route = swapIn ~ swapOut ~ listSwaps ~ cancelSwap ~ swapHistory

peerSwapRoutes
}

}


Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.plugins.peerswap

import fr.acinq.eclair.json.MinimalSerializer
import fr.acinq.eclair.plugins.peerswap.SwapResponses.Response
import fr.acinq.eclair.plugins.peerswap.json.PeerSwapJsonSerializers
import org.json4s.{Formats, JField, JObject, JString}

object ApiSerializers {

object SwapResponseSerializer extends MinimalSerializer({
case x: Response => JString(x.toString)
})

object SwapDataSerializer extends MinimalSerializer({
case x: SwapData => JObject(List(
JField("swap_id", JString(x.request.swapId)),
JField("result", JString(x.result)),
JField("request", JString(x.request.json)),
JField("agreement", JString(x.agreement.json)),
JField("invoice", JString(x.invoice.toString)),
JField("openingTxBroadcasted", JString(x.openingTxBroadcasted.json)),
JField("swapRole", JString(x.swapRole.toString)),
JField("isInitiator", JString(x.isInitiator.toString))
))
})

implicit val formats: Formats = PeerSwapJsonSerializers.formats + SwapResponseSerializer + SwapDataSerializer

}
Original file line number Diff line number Diff line change
@@ -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)
}
}

}
Loading