-
Notifications
You must be signed in to change notification settings - Fork 267
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 support for Peerswap atomic swap based local liquidity management protocol #2342
Conversation
@@ -138,6 +140,7 @@ object NodeParams extends Logging { | |||
val oldSeedPath = new File(datadir, "seed.dat") | |||
val nodeSeedFilename: String = "node_seed.dat" | |||
val channelSeedFilename: String = "channel_seed.dat" | |||
val swapSeedFilename: String = "swap_seed.dat" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that we need a new seed and key manager, we could probably reuse/extend the existing channel key manager
@@ -402,6 +408,11 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA | |||
self ! Peer.OutgoingMessage(msg, peerConnection) | |||
} | |||
|
|||
def replyUnknownSwap(peerConnection: ActorRef, unknownSwapId: String): Unit = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this method does not seem to be used anywhere yet ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapCommands.scala
Outdated
Show resolved
Hide resolved
|
||
final case class SwapInSenderData(channelId: ByteVector32, request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) | ||
|
||
final case class SwapInReceiverData(shortChannelId: ShortChannelId, request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why a not a 32 bytes channel id ? because of the PeerSwap specs that currently uses short channel ids ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally the PeerSwap spec would only use the 32 byte channel id which would be in the SwapInRequest
message so we could then drop channelId
and shortChannelId
from these data classes.
def forwardShortIdAdapter(context: ActorContext[SwapCommand]): ActorRef[Register.ForwardShortId[HasSwapId]] = | ||
context.messageAdapter[Register.ForwardShortId[HasSwapId]](ForwardFailure) | ||
|
||
def fundOpening(request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice)(implicit context: ActorContext[SwapCommand], wallet: OnChainWallet, feeRatePerKw: FeeratePerKw): Unit = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: most methods in this file rely on implicit parameters, which can make the code seem more compact but also harder to read and extend.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I couldn't filed any explanation for how to use pilots to pass local fixed parameters, so I instead I just used currying to delineate them instead of using implicit. I still pass context
as an implicit parameter though. see 4fcceb5.
I could also define and use a local function like the following, but it seemed clearer to use the helper directly.
private def mySend(message: HasSwapId) = send(register, shortChannelId)(message)
Does this approach look ok?
eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInReceiver.scala
Outdated
Show resolved
Hide resolved
eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapInSender.scala
Outdated
Show resolved
Hide resolved
} | ||
} | ||
|
||
case class SwapInRequest(protocolVersion: Long, swapId: String, asset: String, network: String, scid: String, amount: Long, pubkey: String) extends JSonBlobMessage with HasSwapId |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use more restrictive types here, ByteVector32
for swap ids for example ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Until the spec is changed from json to byte encodings our codec will have to work with swapId as a string. I couldn't figure out a way to parse the json into messages with fields of a different type.
eclair-core/src/test/scala/fr/acinq/eclair/swap/SwapTransactionsSpec.scala
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nice work, I think you clearly understood how to architect such a system: the flow between actors looks good.
The gist of my comments for this first review is that we should really focus this PR to be an MVP, as simple as possible. I think we can remove some nice-to-haves that can come later which would simplify the number of events that need to be handled, and will let us focus on the core of the peer swap protocol. This is important to ensure we don't have a bug in the core protocol as it could lead to loss of funds. I know it's tempting to make things better whenever you see a potential improvement (such as making state checks before accepting swaps), but once we have an MVP we can add those later (just keep a list somewhere of all the improvements you want to add in follow-up PRs). Otherwise the PR will be cluttered with a lot of comments on non-critical parts, and we may miss more important protocol issues.
Once that's done and we only have the core protocol to focus on, I think it's important to add the DB support: how we interact with the DB can be critical to avoid loss of funds, so that's a really important part to review and get right.
Also a small comment on types: you can do more typing work on the codecs to make the case class
es directly contain the most accurate type (e.g. ByteVector32
for swapId
, Satoshi
for amounts, etc).
|
||
object SwapData { | ||
|
||
final case class SwapInSenderData(channelId: ByteVector32, request: SwapInRequest, agreement: SwapInAgreement, invoice: Bolt11Invoice, openingTxBroadcasted: OpeningTxBroadcasted) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: don't we say broadcast
, not broadcasted
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's on my list of proposed spec changes, but right now it matches their spec.
def replyTo: ActorRef[Response] | ||
} | ||
sealed trait InitializingMessages extends ReplyToMessages | ||
case class RestoreData(replyTo: ActorRef[Response], swaps: Set[SwapInSenderData]) extends Command with InitializingMessages |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we only care about swap-in? Shouldn't we restore swap-out as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've generalized SwapData to be the same for both actors, see c612e69. I assume that until the opening transaction is broadcast there is nothing relevant to backup - it's just a failed negotiation until that point.
sealed trait SwapId { | ||
def id: String | ||
} | ||
case class SwapInSenderId(id: String) extends SwapId { | ||
def toByteVector32: ByteVector32 = ByteVector32(ByteVector.fromValidHex(id)) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't we simplify this to just use a ByteVector32
all the time since we already have an assumption in toByteVector32
?
More generally, shouldn't this directly be a constructor argument of the swap-in actors (SwapInSender
and SwapInReceiver
) so that we can integrate them into logging automatically? In that case we probably don't need a dedicated case class
, it can simply be a swapId: ByteVector32
or swapId: UUID
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently the json based PeerSwap protocol spec uses a string to represent swapId
in protocol messages. Once the spec evolves to use byte encodings then that would clean up this sort of awkward reinterpretation. Perhaps to simplify review until then, the register should just map from String
, the native type of swapId
, to an actor, for example:
private def registering(swaps: Map[String, ActorRef[SwapCommands.SwapCommand]]): Behavior[Command] = {
eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapRegister.scala
Outdated
Show resolved
Hide resolved
} | ||
|
||
private def registering(swaps: Map[SwapInSenderId, ActorRef[Any]]): Behavior[Command] = { | ||
// TODO: fail requests for swaps on a channel if one already exists for the channel; keep a list of channels with active swaps |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of a separate map, can't we put the channelId
of the channel we're swapping on directly in SwapInSenderId
? Then we only need to check the keys
of the swaps
map to extract the list of impacted channelId
s, it's better than having to maintain two maps that can become inconsistent if we forget to update one in one case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is fixed in the latest PeerSwap plugin branch: SwapRegister.scala
eclair-core/src/main/scala/fr/acinq/eclair/swap/SwapResponses.scala
Outdated
Show resolved
Hide resolved
case CancelReceived(c) if c.swapId == swapId => swapCanceled(PeerCanceled(swapId)) | ||
case CancelReceived(_) => Behaviors.same |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aren't CancelReceived
and CancelRequested
redundant with AbortSwapInSender
? There should be only one way of requesting this actor to gracefully stop
from the outside, this would reduce the number of events we have to handle here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in recent versions.
queryOnChainBalance(wallet) | ||
|
||
receiveSwapMessage[CreateSwapMessages](context, "createSwap") { | ||
case QueryOnChainBalance(Success(onChainBalance: OnChainBalance)) if onChainBalance.confirmed < amount + maxPremium.sat => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if this check and the case ChannelInfoResponse(RES_GET_CHANNEL_INFO(_, _, _, d: DATA_NORMAL)) if d.commitments.availableBalanceForReceive.truncateToSatoshi < amount
are really necessary, especially for a first version.
I'd rather stay simple at first and just abort later when we realize we actually cannot pay (either when failing to fund an on-chain transaction or failing to send an HTLC).
We can add nice-to-have checks later, I think we should just focus on an MVP at first, which will help focus on the core of the protocol.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point; this check is removed in recent versions.
case class SwapInStatus(swapId: String, actor: String, behavior: String, channelId: ByteVector32, request: SwapInRequest, agreement_opt: Option[SwapInAgreement] = None, invoice_opt: Option[Bolt11Invoice] = None, openingTxBroadcasted_opt: Option[OpeningTxBroadcasted] = None) extends Status { | ||
override def toString: String = s"$actor[$behavior]: $swapId, $channelId, $request, $agreement_opt, $invoice_opt, $openingTxBroadcasted_opt" | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should expose that much internal information, actor
and behavior
are really internal details IMO.
But I think it would be valuable to have something like:
sealed trait SwapInStatus extends Status
case class AwaitingSwapInAgreement(...) extends SwapInStatus
case class AwaitOpeningTxConfirmation(...) extends SwapInStatus
...
The list of statuses we should expose should map to the protocol statuses where we're waiting for an external event (a response from our peer or a blockchain event like tx confirmation). We should not include transient statuses (such as CreatingOpeningTx
for example), because these statuses will automatically move to a "real" status once an internal call completes, they would only clutter the status trait for no good reason. When the actor is in one of these transient statuses and receives a GetStatus
request, it should simply delay it and it will eventually be handled once we're in a "real" status.
This is quite subtle, does it make sense (and do you think it's a good idea)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point! I've updated GetStatus to emit the minimal set of user meaningful messages, and moved the verbose information to the debug log: 3403272
def payInvoice(nodeParams: NodeParams)(paymentInitiator: actor.ActorRef, swapId: String, invoice: Bolt11Invoice): Unit = | ||
paymentInitiator ! SendPaymentToNode(invoice.amount_opt.get, invoice, nodeParams.maxPaymentAttempts, Some(swapId), assistedRoutes = invoice.routingInfo, nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams, blockUntilComplete = true) | ||
|
||
def watchForPayment(watch: Boolean)(implicit context: ActorContext[SwapCommand]): Unit = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good idea! But I think we should be more specific instead of registering to PaymentEvent
. We should explicitly register only to PaymentReceived
when we generated an invoice and wait for it to be paid, and we should only register to PaymentSent
and PaymentFailed
when we're paying an invoice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in ad33a79. This also forced some reorganization of the coded that handles resume/restart scenarios because a Taker actor check points right before trying to pay the invoice.
Now the payInvoice
method first checks if the payment is in the database before paying the invoice. This ensures the Taker
will not be pay the fee or claim invoices twice after a restart or resume.
Codecov Report
@@ Coverage Diff @@
## master #2342 +/- ##
==========================================
- Coverage 84.79% 84.19% -0.60%
==========================================
Files 199 209 +10
Lines 15584 16081 +497
Branches 662 638 -24
==========================================
+ Hits 13214 13540 +326
- Misses 2370 2541 +171
|
- use different watch message to wait for csv - fix handling of received CancelSwap message - send OpeningTxBroadcasted message before opening confirmed - fixed claim by coop and csv transactions by including premium - do not handle user commands during swap-in create - do not send CancelSwap message for failures during swap-in create - reverse txid when making transaction outpoints - clarify comments to distinguish when tx is published vs confirmed
…Register Add support for testing swaps
- use different watch message to wait for csv - fix handling of received CancelSwap messages - send OpeningTxBroadcasted message before opening confirmed - fixed claim by coop and csv transactions by including premium - remove user commands during createSwap - do not send CancelSwap message for failures during createSwap - reverse txid when making transaction outpoints - clarify comments to distinguish when tx is published vs confirmed
- use only shortChannelId, not channelId - add isInitiator so same SwapData class works for both senders and receivers
Thanks for the detailed review so far and helpful advice. I agree that it make sense to strip it down as much as possible to the critical protocol elements. Now that the basic architecture appears correct, I have gone ahead and finished off the protocol by adding the remaining states necessary to support the I will now look at incorporating your |
I'd also like to rename my two main swap actors as follows:
The |
fb4ea5f
to
6ca8fa5
Compare
Closing this PR and opening #2496 which moves this feature into an Eclair Plugin with a cleaner commit history. |
This PR implements the draft PeerSwap protocol .
We create a
SwapRegister
actor at setup which will spawn swap sender actors in response to user requests. TheSwapRegister
will also spawn swap receiver actors in response to a swap requests received from peers. TheSwapRegister
forwards swap messages based on theirswapId
field to the corresponding local swap actor or remote peer. When a swap actor has completed it simply stops which triggers theSwapRegister
to remove it.Project TODOs:
Protocol implementation TODOs:
PeerSwap specification suggestions/questions to discuss: