Skip to content

Commit 30c4ddf

Browse files
committed
feat: add 2-hop swaps and channel_adjacency hint
Implements 2-hop swap negotiation and pinned 2-hop payments (u→m→v). Adds poll capability extension for 2-hop candidate discovery via channel_adjacency (legacy neighbors_ad supported). Updates RPC/proto/docs and adds unit + integration tests.
1 parent b39600d commit 30c4ddf

41 files changed

Lines changed: 2987 additions & 634 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

clightning/clightning.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
log2 "log"
1010
"os"
11+
"sort"
1112
"time"
1213

1314
"github.com/btcsuite/btcd/chaincfg"
@@ -198,6 +199,7 @@ type PeerChannel struct {
198199
PeerConnected bool `json:"peer_connected"`
199200
State string `json:"state"`
200201
ShortChannelId string `json:"short_channel_id,omitempty"`
202+
Private bool `json:"private,omitempty"`
201203
TotalMsat glightning.Amount `json:"total_msat,omitempty"`
202204
ToUsMsat glightning.Amount `json:"to_us_msat,omitempty"`
203205
ReceivableMsat glightning.Amount `json:"receivable_msat,omitempty"`
@@ -294,6 +296,55 @@ func (cl *ClightningClient) ReceivableMsat(scid string) (uint64, error) {
294296
return 0, fmt.Errorf("could not find a channel with scid: %s", scid)
295297
}
296298

299+
func (cl *ClightningClient) ChannelsToPeer(peerPubkey string) ([]string, error) {
300+
var res ListPeerChannelsResponse
301+
err := cl.glightning.Request(ListPeerChannelsRequest{Id: peerPubkey}, &res)
302+
if err != nil {
303+
return nil, err
304+
}
305+
306+
var scids []string
307+
for _, ch := range res.Channels {
308+
if ch.PeerId != peerPubkey || ch.ShortChannelId == "" {
309+
continue
310+
}
311+
if err := cl.checkChannel(ch); err != nil {
312+
continue
313+
}
314+
scids = append(scids, lightning.Scid(ch.ShortChannelId).ClnStyle())
315+
}
316+
sort.Strings(scids)
317+
return scids, nil
318+
}
319+
320+
func (cl *ClightningClient) ListChannels(ctx context.Context) ([]peersync.Channel, error) {
321+
var res ListPeerChannelsResponse
322+
if err := cl.glightning.Request(ListPeerChannelsRequest{}, &res); err != nil {
323+
return nil, err
324+
}
325+
326+
channels := make([]peersync.Channel, 0, len(res.Channels))
327+
for _, ch := range res.Channels {
328+
if ch.PeerId == "" || ch.ShortChannelId == "" {
329+
continue
330+
}
331+
peerID, err := peersync.NewPeerID(ch.PeerId)
332+
if err != nil {
333+
continue
334+
}
335+
336+
channels = append(channels, peersync.Channel{
337+
Peer: peerID,
338+
ChannelID: 0,
339+
ShortChannelID: lightning.Scid(ch.ShortChannelId).ClnStyle(),
340+
Active: ch.PeerConnected && ch.State == "CHANNELD_NORMAL",
341+
Public: !ch.Private,
342+
})
343+
}
344+
345+
return channels, nil
346+
}
347+
297348
// checkChannel performs a set of sanity checks id the channel is eligible for
298349
// a swap of amtSat
299350
func (cl *ClightningClient) checkChannel(ch PeerChannel) error {
@@ -503,6 +554,66 @@ func (cl *ClightningClient) PayInvoiceViaChannel(payreq string, scid string) (pr
503554
return preimage, nil
504555
}
505556

557+
func (cl *ClightningClient) PayInvoiceVia2HopRoute(payreq string, outgoingScid string, incomingScid string, intermediaryPubkey string) (preimage string, err error) {
558+
bolt11, err := cl.glightning.DecodeBolt11(payreq)
559+
if err != nil {
560+
return "", err
561+
}
562+
563+
if bolt11.AmountMsat.MSat() == 0 {
564+
return "", fmt.Errorf("invoice has no amount")
565+
}
566+
567+
outgoingScid = lightning.Scid(outgoingScid).ClnStyle()
568+
incomingScid = lightning.Scid(incomingScid).ClnStyle()
569+
570+
route, err := cl.glightning.GetRoute(
571+
bolt11.Payee,
572+
bolt11.AmountMsat.MSat(),
573+
10.0,
574+
uint(bolt11.MinFinalCltvExpiry+1),
575+
"",
576+
0.0001,
577+
nil,
578+
2,
579+
)
580+
if err != nil {
581+
return "", err
582+
}
583+
584+
if len(route) != 2 {
585+
return "", fmt.Errorf("could not find 2-hop route")
586+
}
587+
if route[0].Id != intermediaryPubkey {
588+
return "", fmt.Errorf("route does not use expected intermediary")
589+
}
590+
if route[0].ShortChannelId != outgoingScid || route[1].ShortChannelId != incomingScid {
591+
return "", fmt.Errorf("route does not use expected channels")
592+
}
593+
594+
label := randomString()
595+
_, err = cl.glightning.SendPay(
596+
route,
597+
bolt11.PaymentHash,
598+
label,
599+
bolt11.AmountMsat.MSat(),
600+
payreq,
601+
bolt11.PaymentSecret,
602+
0,
603+
)
604+
if err != nil {
605+
return "", err
606+
}
607+
608+
res, err := cl.glightning.WaitSendPay(bolt11.PaymentHash, 0)
609+
if err != nil {
610+
return "", err
611+
}
612+
613+
preimage = res.PaymentPreimage
614+
return preimage, nil
615+
}
616+
506617
// RebalancePayment handles the lightning payment that should re-balance the
507618
// channel.
508619
func (cl *ClightningClient) RebalancePayment(payreq string, channel string) (preimage string, err error) {

clightning/clightning_commands.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ type SwapOut struct {
180180
Asset string `json:"asset"`
181181
PremiumLimitRatePPM int64 `json:"premium_rate_limit_ppm"`
182182
Force bool `json:"force"`
183+
PeerPubkey string `json:"peer_pubkey,omitempty"`
183184
cl *ClightningClient `json:"-"`
184185
}
185186

@@ -227,14 +228,20 @@ func (l *SwapOut) Call() (jrpc2.Result, error) {
227228
return nil, errors.New("fundingChannels is not connected")
228229
}
229230

231+
intermediaryPeerId := fundingChannels.Id
232+
peerId := intermediaryPeerId
233+
if l.PeerPubkey != "" {
234+
peerId = l.PeerPubkey
235+
}
236+
230237
// Skip this check when `force` is set.
231238
if !l.Force {
232-
if l.cl.peerSync == nil || !l.cl.peerSync.HasCompatiblePeer(fundingChannels.Id) {
239+
if l.cl.peerSync == nil || !l.cl.peerSync.HasCompatiblePeer(peerId) {
233240
return nil, fmt.Errorf("peer does not run peerswap")
234241
}
235242
}
236243

237-
if !l.cl.isPeerConnected(fundingChannels.Id) {
244+
if !l.cl.isPeerConnected(peerId) {
238245
return nil, fmt.Errorf("peer is not connected")
239246
}
240247

@@ -255,7 +262,12 @@ func (l *SwapOut) Call() (jrpc2.Result, error) {
255262
}
256263

257264
pk := l.cl.GetNodeId()
258-
swapOut, err := l.cl.swaps.SwapOut(fundingChannels.Id, l.Asset, l.ShortChannelId, pk, l.SatAmt, l.PremiumLimitRatePPM)
265+
var swapOut *swap.SwapStateMachine
266+
if l.PeerPubkey != "" && l.PeerPubkey != intermediaryPeerId {
267+
swapOut, err = l.cl.swaps.SwapOutTwoHop(peerId, l.Asset, l.ShortChannelId, pk, l.SatAmt, l.PremiumLimitRatePPM, intermediaryPeerId)
268+
} else {
269+
swapOut, err = l.cl.swaps.SwapOut(peerId, l.Asset, l.ShortChannelId, pk, l.SatAmt, l.PremiumLimitRatePPM)
270+
}
259271
if err != nil {
260272
return nil, err
261273
}
@@ -302,6 +314,7 @@ type SwapIn struct {
302314
Asset string `json:"asset"`
303315
PremiumLimitRatePPM int64 `json:"premium_limit_ppm"`
304316
Force bool `json:"force"`
317+
PeerPubkey string `json:"peer_pubkey,omitempty"`
305318
cl *ClightningClient `json:"-"`
306319
}
307320

@@ -349,14 +362,20 @@ func (l *SwapIn) Call() (jrpc2.Result, error) {
349362
return nil, errors.New("fundingChannels is not connected")
350363
}
351364

365+
intermediaryPeerId := fundingChannels.Id
366+
peerId := intermediaryPeerId
367+
if l.PeerPubkey != "" {
368+
peerId = l.PeerPubkey
369+
}
370+
352371
// Skip this check when `force` is set.
353372
if !l.Force {
354-
if l.cl.peerSync == nil || !l.cl.peerSync.HasCompatiblePeer(fundingChannels.Id) {
373+
if l.cl.peerSync == nil || !l.cl.peerSync.HasCompatiblePeer(peerId) {
355374
return nil, fmt.Errorf("peer does not run peerswap")
356375
}
357376
}
358377

359-
if !l.cl.isPeerConnected(fundingChannels.Id) {
378+
if !l.cl.isPeerConnected(peerId) {
360379
return nil, fmt.Errorf("peer is not connected")
361380
}
362381
switch l.Asset {
@@ -373,7 +392,12 @@ func (l *SwapIn) Call() (jrpc2.Result, error) {
373392
}
374393

375394
pk := l.cl.GetNodeId()
376-
swapIn, err := l.cl.swaps.SwapIn(fundingChannels.Id, l.Asset, l.ShortChannelId, pk, l.SatAmt, l.PremiumLimitRatePPM)
395+
var swapIn *swap.SwapStateMachine
396+
if l.PeerPubkey != "" && l.PeerPubkey != intermediaryPeerId {
397+
swapIn, err = l.cl.swaps.SwapInTwoHop(peerId, l.Asset, l.ShortChannelId, pk, l.SatAmt, l.PremiumLimitRatePPM, intermediaryPeerId)
398+
} else {
399+
swapIn, err = l.cl.swaps.SwapIn(peerId, l.Asset, l.ShortChannelId, pk, l.SatAmt, l.PremiumLimitRatePPM)
400+
}
377401
if err != nil {
378402
return nil, err
379403
}
@@ -586,6 +610,7 @@ func (l *ListPeers) Call() (jrpc2.Result, error) {
586610

587611
view := format.BuildPeerView(peer.Id, capability, swaps)
588612
peerSwapPeer := peerswaprpc.NewPeerSwapPeerFromView(view)
613+
peerSwapPeer.ChannelAdjacency = peerswaprpc.ChannelAdjacencyFromPeerState(peerState)
589614
channels, err := l.cl.glightning.ListChannelsBySource(peer.Id)
590615
if err != nil {
591616
return nil, err

clightning/options.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const (
1010
liquidRpcUserOption = "peerswap-elementsd-rpcuser"
1111
liquidRpcPasswordOption = "peerswap-elementsd-rpcpassword"
1212
liquidRpcPasswordFilepathOption = "peerswap-elementsd-rpcpasswordfile"
13-
liquidSwapsOption = "peerswap-elementsd-swaps"
13+
liquidSwapsOption = "peerswap-elementsd-swaps"
1414
liquidRpcWalletOption = "peerswap-elementsd-rpcwallet"
1515

1616
bitcoinRpcHostOption = "peerswap-bitcoin-rpchost"
@@ -53,7 +53,7 @@ type PeerswapClightningConfig struct {
5353
LiquidRpcHost string `json:"liquid.rpchost"`
5454
LiquidRpcPort uint `json:"liquid.rpcport"`
5555
LiquidRpcWallet string `json:"liquid.rpcwallet"`
56-
LiquidSwaps bool `json:"liquid.swaps"`
56+
LiquidSwaps bool `json:"liquid.swaps"`
5757

5858
LWKSignerName string `json:"lwksignername"`
5959
LWKWalletName string `json:"lwkwalletname"`

cmd/peerswaplnd/pscli/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ var (
7575
Name: "peer_pubkey",
7676
Required: true,
7777
}
78+
swapPeerPubkeyFlag = cli.StringFlag{
79+
Name: "peer_pubkey",
80+
Usage: "swap negotiation peer pubkey (optional; set for 2-hop swaps)",
81+
Required: false,
82+
}
7883
PremiumLimitRatePPMFlag = cli.Uint64Flag{
7984
Name: "premium_limit_rate_ppm",
8085
Usage: "premium limit for a swap in parts per million",
@@ -104,6 +109,7 @@ var (
104109
channelIdFlag,
105110
assetFlag,
106111
PremiumLimitRatePPMFlag,
112+
swapPeerPubkeyFlag,
107113
},
108114
Action: swapOut,
109115
}
@@ -116,6 +122,7 @@ var (
116122
channelIdFlag,
117123
assetFlag,
118124
PremiumLimitRatePPMFlag,
125+
swapPeerPubkeyFlag,
119126
},
120127
Action: swapIn,
121128
}
@@ -291,6 +298,7 @@ func swapIn(ctx *cli.Context) error {
291298
SwapAmount: ctx.Uint64(satAmountFlag.Name),
292299
Asset: ctx.String(assetFlag.Name),
293300
PremiumLimitRatePpm: ctx.Int64(PremiumLimitRatePPMFlag.Name),
301+
PeerPubkey: ctx.String(swapPeerPubkeyFlag.Name),
294302
})
295303
if err != nil {
296304
return err
@@ -312,6 +320,7 @@ func swapOut(ctx *cli.Context) error {
312320
SwapAmount: ctx.Uint64(satAmountFlag.Name),
313321
Asset: ctx.String(assetFlag.Name),
314322
PremiumLimitRatePpm: ctx.Int64(PremiumLimitRatePPMFlag.Name),
323+
PeerPubkey: ctx.String(swapPeerPubkeyFlag.Name),
315324
})
316325
if err != nil {
317326
return err

docs/peer-protocol.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ The `protocol_version` is included to allow for possible changes in the future.
5454
PeerSwap utilizes custom messages as described in [BOLT#1](https://github.com/Lightning/bolts/blob/master/01-messaging.md). The types are in range `42069`-`42085`. The `payload` is JSON encoded.
5555

5656
* Both nodes MUST ignore unexpected Messages.
57+
* Both nodes MUST ignore unknown JSON fields in received payloads.
5758
* During a swap the involved peers MUST ensure, that there is only one active swap per channel.
5859
* Swaps are identified by a unique `swap_id` that MUST be mapped to the peers `pubkey` and MUST be checked on every message.
5960

@@ -116,7 +117,8 @@ This phase of the swap is used to agree on the swap conditions.
116117
network: string,
117118
scid: string,
118119
amount: uint64,
119-
pubkey: string
120+
pubkey: string,
121+
twohop: object
120122
}
121123
```
122124

@@ -141,6 +143,10 @@ This phase of the swap is used to agree on the swap conditions.
141143

142144
`pubkey` is a 33 byte compressed public key generated by the swap initiator. It is used for the spending paths in the [`opening_transaction`](#opening-transaction).
143145

146+
`twohop` is OPTIONAL and indicates a 2-hop swap negotiation. If present, it MUST contain:
147+
148+
* `intermediary_pubkey`: the 33 byte compressed public key (hex) of the intermediary node.
149+
144150
##### Requirements
145151

146152
The sending node (swap [maker](#maker)/[initiator](#initiator)):
@@ -179,7 +185,8 @@ The receiving node (swap [taker](#taker)/[responder](#responder)):
179185
protocol_version: uint64,
180186
swap_id: string,
181187
pubkey: string,
182-
premium: int64
188+
premium: int64,
189+
twohop: object
183190
}
184191
```
185192

@@ -191,6 +198,10 @@ The receiving node (swap [taker](#taker)/[responder](#responder)):
191198

192199
`premium` is a compensation in Sats that the swap partner wants to be paid in order to participate in the swap.
193200

201+
`twohop` is OPTIONAL and indicates a 2-hop swap negotiation. If present, it MAY contain:
202+
203+
* `incoming_scid`: the short channel id that the responder wants to be used as the incoming channel (2nd hop) for the 2-hop payment.
204+
194205
##### Requirements
195206

196207
The sending node (swap [taker](#taker)/[responder](#responder)):
@@ -242,7 +253,8 @@ This phase of the swap is used to agree on the swap conditions. It involves one
242253
network: string,
243254
scid: string,
244255
amount: uint64,
245-
pubkey: string
256+
pubkey: string,
257+
twohop: object
246258
}
247259
```
248260
`protocol_version` is the version of the PeerSwap peer protocol the sending node uses.
@@ -266,6 +278,10 @@ This phase of the swap is used to agree on the swap conditions. It involves one
266278

267279
`pubkey` is a 33 byte compressed public key generated by the initiator. It is used for the spending paths in the [`opening_transaction`](#opening-transaction).
268280

281+
`twohop` is OPTIONAL and indicates a 2-hop swap negotiation. If present, it MUST contain:
282+
283+
* `intermediary_pubkey`: the 33 byte compressed public key (hex) of the intermediary node.
284+
269285
##### Requirements
270286

271287
The sending node (swap [taker](#taker)/[initiator](#initiator)):
@@ -305,7 +321,8 @@ The receiving node (swap responder):
305321
swap_id: string,
306322
pubkey: string,
307323
payreq: string,
308-
premium: uint64
324+
premium: uint64,
325+
twohop: object
309326
}
310327
```
311328

@@ -319,6 +336,10 @@ The receiving node (swap responder):
319336

320337
`premium` is a compensation in Sats that the swap partner wants to be paid in order to participate in the swap.
321338

339+
`twohop` is OPTIONAL and indicates a 2-hop swap negotiation. If present, it MAY contain:
340+
341+
* `incoming_scid`: the short channel id that the responder wants to be used as the incoming channel (2nd hop) for the 2-hop payment.
342+
322343
##### Requirements
323344

324345
The sending node (swap [maker](#maker)/[responder](#responder)):
@@ -569,4 +590,4 @@ This is the way to finish a swap if the invoice was not paid and the taker did n
569590
* for `btc` as asset: 0x3F0 corresponding to the CSV of 1008
570591
* for `lbtc` as asset: 0x3C corresponding to the CSV of 60
571592
* txin[0] script bytes: 0
572-
* txin[0] witness: `<signature_for_B> <redeem_script>`
593+
* txin[0] witness: `<signature_for_B> <redeem_script>`

0 commit comments

Comments
 (0)