Skip to content

Commit 8f2fa13

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 8f2fa13

Some content is hidden

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

43 files changed

+3774
-634
lines changed

2hop.execplan.md

Lines changed: 559 additions & 0 deletions
Large diffs are not rendered by default.

2hop.md

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
Define the minimal extension that enables atomic swaps even over a 2-hop path `u → m → v`.
2+
3+
## Motivation
4+
5+
PeerSwap can perform atomic swap between direct nodes. However, in real networks many two-hop paths exist where exactly one intermediary node forms the route, `u → m → v`. We therefore want to enable swaps over these paths as well.
6+
7+
* Release liquidity without opening additional channels
8+
* No cooperation or trust required from the relay node m
9+
* Keep using the existing one-hop protocol on the endpoint nodes u and v as is
10+
11+
## Network Model
12+
13+
```
14+
u──ch₁──m──ch₂──v
15+
↕ p2p (tcp) ↕
16+
```
17+
18+
* `ch₁` and `ch₂` are public LN channels
19+
* `u` and `v` are already connected via p2p (TCP) and can exchange BOLT#1 custom messages
20+
* It is irrelevant whether the relay node `m` supports PeerSwap or not
21+
22+
**Notation**
23+
24+
* `u`: initiator
25+
* `m`: intermediary
26+
* `v`: responder
27+
28+
## Messages
29+
30+
Policy
31+
32+
* No new message types are introduced.
33+
* The existing JSON messages are extended with additional optional fields.
34+
* The semantics of existing fields are unchanged.
35+
* All added fields are OPTIONAL; nodes that do not understand them simply ignore them.
36+
* The JSON container twohop indicates “2-hop discovery mode”; the message is interpreted as 2-hop only when this container is present.
37+
38+
Affected messages (type numbers stay the same)
39+
40+
* swap_in_request (JSON, type = 42069)
41+
* swap_in_agreement (JSON, type = 42073)
42+
* swap_out_request (JSON, type = 42071)
43+
* swap_out_agreement (JSON, type = 42075)
44+
* opening_tx_broadcasted and the rest remain unchanged
45+
46+
### `swap_out_request`
47+
48+
Additional Fields
49+
50+
* `twohop`: object — A container that signals 2-hop discovery mode.
51+
The receiver treats the message as 2-hop only when this object is present.
52+
* `twohop.intermediary_pubkey`: string (33-byte compressed pubkey, hex) —
53+
the pubkey of the intermediary node **m**
54+
55+
Behaviour
56+
57+
* When `twohop` is present, the receiver **v** derives its local channel
58+
**`ch₂` (m–v)** from `intermediary_pubkey`, computes its own
59+
`receivable_msat`, and decides whether the requested amount is within the
60+
executable range.
61+
62+
Compatibility
63+
64+
* A legacy node that does not understand `twohop` ignores the unknown field and
65+
will safely reject the request for ordinary reasons (e.g. the `scid`
66+
is not a direct channel or `amount = 0`).
67+
68+
### `swap_out_agreement`
69+
70+
Additional Fields (all OPTIONAL)
71+
72+
* `twohop`: object — The result of 2-hop discovery.
73+
* `twohop.incoming_scid`: string (e.g. `"x:y:z"`) —
74+
the short_channel_id of **`ch₂` (m–v)**
75+
76+
Behaviour
77+
78+
* If `twohop` is present, the receiver **u** pays the `payreq` via a 2-hop route
79+
that includes `incoming_scid`.
80+
81+
Notes
82+
83+
* Route hints are **not** used; the sender pins the route itself (doable with
84+
standard LN APIs).
85+
* Route hints cannot reliably force a fixed route.
86+
87+
### `swap_in_request`
88+
89+
Additional Fields
90+
91+
* `twohop`: object — Container that signals 2-hop discovery mode.
92+
* `twohop.intermediary_pubkey`: string (33-byte compressed pubkey, hex) —
93+
the pubkey of the intermediary node **m**
94+
95+
Behaviour
96+
97+
* When `twohop` is present, the receiver **v** identifies its local
98+
**`ch₂` (m–v)** from `intermediary_pubkey` and checks whether the requested
99+
**`amount`** can be sent.
100+
101+
Compatibility
102+
103+
* A legacy node that does not understand `twohop` ignores the field and rejects
104+
the request using the current rules (`scid`, `amount`, etc.).
105+
106+
### `swap_in_agreement`
107+
108+
Additional Fields (all OPTIONAL)
109+
110+
* `twohop`: object — The result of 2-hop discovery.
111+
* `twohop.incoming_scid`: string (e.g. `"x:y:z"`) —
112+
the short_channel_id of **`ch₂` (m–v)**, viewed from **v** (m → v)
113+
114+
Behaviour
115+
116+
* If `twohop` is present, the responder **v**, when making the payment, verifies
117+
that the receivable amount on `incoming_scid`** is sufficient for
118+
**`amount`.
119+
120+
## Doing the Swap
121+
122+
* When `twohop` is present, the swap maker locates its local
123+
**`ch₁` (u–m)** using `intermediary_pubkey`, then pays the `payreq` over the
124+
2-hop route that includes `scid`.
125+
126+
## Extension of the `poll` Message (Implemented; optional to rely on)
127+
128+
### Purpose
129+
Let the intermediary **m** periodically advertise its own channel adjacency list
130+
(public + active channels from **m** to immediate neighbors).
131+
Doing so makes it easier for **u** and **v** to discover potential 2-hop routes.
132+
133+
### Method
134+
Add an optional `channel_adjacency` object to the existing `poll` JSON.
135+
Nodes that do not understand the field simply ignore it, so backward compatibility is preserved.
136+
In most cases it is sufficient to send it on the same schedule as the ordinary `poll`.
137+
138+
* The broadcast interval is fixed; freshness is **not** guaranteed.
139+
* Because there is no defence against false reports, using this extension is **optional**.
140+
* When the swap is actually executed, the amount is finalised with the 2-hop discovery step.
141+
* Legacy note: older drafts used the name `neighbors_ad` with keys
142+
`v/public_only/limit/entries`. This repository accepts both representations on
143+
receive, but emits `channel_adjacency` going forward.
144+
145+
Example of the extended `poll` message:
146+
147+
```json
148+
{
149+
"version": 5,
150+
"assets": ["BTC", "LBTC"],
151+
"peer_allowed": true,
152+
"btc_swap_in_premium_rate_ppm": 100,
153+
"btc_swap_out_premium_rate_ppm": 200,
154+
"lbtc_swap_in_premium_rate_ppm": 50,
155+
"lbtc_swap_out_premium_rate_ppm": 150,
156+
"channel_adjacency": {
157+
"schema_version": 1,
158+
"public_channels_only": true,
159+
"max_neighbors": 20,
160+
"truncated": false,
161+
"neighbors": [
162+
{
163+
"node_id": "<pubkey-of-neighbor>",
164+
"channels": [
165+
{
166+
"channel_id": 1234567890,
167+
"short_channel_id": "1x2x3",
168+
"active": true
169+
}
170+
]
171+
}
172+
]
173+
}
174+
}
175+
```
176+
177+
### Sending policy
178+
When transmitting, the LN node extracts public and active neighbours from its channel list.
179+
By default no balance information is sent (privacy).
180+
If the number of neighbours exceeds `max_neighbors`, prune deterministically and set `truncated=true`.
181+
Send triggers: the regular `poll` interval and the reply to an initial `request_poll`.
182+
183+
### Receiver behaviour
184+
A listener can build a list of 2-hop candidates — even to nodes with which it does **not** share a direct channel.
185+
186+
In this repository, the last received `channel_adjacency` is stored in the peersync database and is surfaced via `listpeers` as `channel_adjacency`.
187+
188+
Conceptual example of a 2-hop candidate list:
189+
190+
```json
191+
[
192+
{
193+
"nodeid": "<v_pubkey>",
194+
"intermediary_nodeid": "<m_pubkey>",
195+
"outgoing_scid": "<ch1 u–m>",
196+
"incoming_scid": "<ch2 m–v>",
197+
"spendable_msat": 0,
198+
"receivable_msat": 0,
199+
"sent": {
200+
"total_swaps_out": 2,
201+
"total_swaps_in": 1,
202+
"total_sats_swapped_out": 5300000,
203+
"total_sats_swapped_in": 302938
204+
},
205+
"received": {
206+
"total_swaps_out": 1,
207+
"total_swaps_in": 0,
208+
"total_sats_swapped_out": 2400000,
209+
"total_sats_swapped_in": 0
210+
},
211+
"total_fee_paid": 6082,
212+
"swap_in_premium_rate_ppm": 100,
213+
"swap_out_premium_rate_ppm": 100
214+
}
215+
]
216+
```
217+
218+
When actually executing a swap, the implementation can pick transparently from this list, but — given equal conditions — a direct 1-hop path should always be preferred.
219+
220+
## Peer-Discovery Strategy
221+
222+
The `poll` extension makes it easier to estimate whether a 2-hop swap is possible and what the rough capacity might be.
223+
Even without it, discovery can still be done by probing directly, but this is less efficient.
224+
225+
| Strategy | support required on m? | Pros | Cons |
226+
| ------------------------------------------------------------ | ---------------------- | ---------------- | -------------------------------------- |
227+
| A – Poll extension (periodic broadcast of `connected_peers`) | yes | Low latency | Useless if m does not support PeerSwap |
228+
| B – Direct probe | no | Works everywhere | Less efficient |

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) {

0 commit comments

Comments
 (0)