Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ out/test-builds/peerswap
out/test-builds/pscli

# # build output of tools
tools/bin/
tools/bin/
out/test-builds/peerswap-v7.0.0
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ ${TEST_BIN_DIR}/peerswap:
chmod a+x ${TEST_BIN_DIR}/peerswap

# Test section. Has commands for local and ci testing.
ifeq ($(RUN_INTEGRATION_TESTS),1)
test: legacy-test-plugin
endif
test:
PAYMENT_RETRY_TIME=5 go test -tags dev -tags fast_test -race -timeout=10m -v ./...
.PHONY: test
Expand Down Expand Up @@ -213,3 +216,21 @@ test-matrix-misc_3: test-bins
.PHONY: test-matrix-lnd
test-matrix-lnd: test-bins
${INTEGRATION_TEST_ENV} go test ${INTEGRATION_TEST_OPTS} ./lnd

# Only the recovery integration test uses this historical binary. It starts a
# contract before upgrading the same data directory to the suspended version.
.PHONY: legacy-test-plugin
legacy-test-plugin:
@set -eu; \
legacy_commit=578c740e8684e2121b6f8343569f262f6bd69f12; \
if ! git cat-file -e "$$legacy_commit^{commit}" 2>/dev/null; then \
git fetch origin tag v7.0.0; \
fi; \
legacy_source=$$(mktemp -d); \
trap 'rm -rf "$$legacy_source"' EXIT; \
git archive "$$legacy_commit" | tar -x -C "$$legacy_source"; \
mkdir -p "$(CURDIR)/$(TEST_BIN_DIR)"; \
cd "$$legacy_source"; \
go build -tags 'dev fast_test' -o "$(CURDIR)/$(TEST_BIN_DIR)/peerswap-v7.0.0" ./cmd/peerswap-plugin

test-matrix-misc test-matrix-misc_2: legacy-test-plugin
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
> [!CAUTION]
> **Liquid network security incident — new L-BTC swaps disabled in v7.0.1.**
>
> v7.0.1 disables new L-BTC swap requests in both directions. Existing swap
> recovery mechanisms remain enabled, but recovery depends on network
> availability and the state of the recovered chain and is not guaranteed.
> Keep your PeerSwap data and wallet backups, and keep your Liquid backend
> configured for recovery. Bitcoin swaps are unchanged by this update.
>
> See the [Liquid Network statement](https://x.com/Liquid_BTC/status/2096696272447218108)
> and follow [@Liquid_BTC](https://x.com/Liquid_BTC) for official updates.
> This notice will be updated as more information becomes available.
> Last updated: 2026-09-07 UTC.

<details>
<summary>Screenshot of the Liquid Network statement (captured 2026-09-07)</summary>

[![Liquid Network's public statement about the security incident](./docs/img/liquid-incident-2026-09-06.jpg)](https://x.com/Liquid_BTC/status/2096696272447218108)

</details>

![peerswap logo](./docs/img/peerswap-logo.png)
# PeerSwap

Expand Down
8 changes: 8 additions & 0 deletions clightning/clightning_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"github.com/elementsproject/peerswap/swap"
)

const liquidAsset = "lbtc"

type SwapCanceledError string

func (e SwapCanceledError) Error() string {
Expand Down Expand Up @@ -194,6 +196,9 @@ func (l *SwapOut) Name() string {
}

func (l *SwapOut) Call() (jrpc2.Result, error) {
if l.Asset == liquidAsset {
return nil, swap.ErrNewLiquidSwapsDisabled
}
if !l.cl.isReady {
return nil, ErrWaitingForReady
}
Expand Down Expand Up @@ -316,6 +321,9 @@ func (l *SwapIn) Name() string {
}

func (l *SwapIn) Call() (jrpc2.Result, error) {
if l.Asset == liquidAsset {
return nil, swap.ErrNewLiquidSwapsDisabled
}
if !l.cl.isReady {
return nil, ErrWaitingForReady
}
Expand Down
22 changes: 22 additions & 0 deletions clightning/liquid_suspension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package clightning

import (
"errors"
"testing"

"github.com/elementsproject/peerswap/swap"
)

func TestLiquidSuspensionBeforeBackendAccess(t *testing.T) {
// No CLN client or wallet: rejection must work even when the backend is down.
for _, force := range []bool{false, true} {
_, err := (&SwapIn{Asset: liquidAsset, Force: force}).Call()
if !errors.Is(err, swap.ErrNewLiquidSwapsDisabled) {
t.Fatalf("swap-in: %v", err)
}
_, err = (&SwapOut{Asset: liquidAsset, Force: force}).Call()
if !errors.Is(err, swap.ErrNewLiquidSwapsDisabled) {
t.Fatalf("swap-out: %v", err)
}
}
}
Binary file added docs/img/liquid-incident-2026-09-06.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions peerswaprpc/liquid_suspension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package peerswaprpc

import (
"context"
"errors"
"testing"

"github.com/elementsproject/peerswap/swap"
)

func TestLiquidSuspensionBeforeBackendAccess(t *testing.T) {
// No LND client or wallet: rejection must not require a healthy backend.
server := &PeerswapServer{}
for _, force := range []bool{false, true} {
_, err := server.SwapIn(context.Background(), &SwapInRequest{Asset: liquidAsset, Force: force})
if !errors.Is(err, swap.ErrNewLiquidSwapsDisabled) {
t.Fatalf("swap-in: %v", err)
}
_, err = server.SwapOut(context.Background(), &SwapOutRequest{Asset: liquidAsset, Force: force})
if !errors.Is(err, swap.ErrNewLiquidSwapsDisabled) {
t.Fatalf("swap-out: %v", err)
}
}
}
8 changes: 8 additions & 0 deletions peerswaprpc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"github.com/lightningnetwork/lnd/lnwire"
)

const liquidAsset = "lbtc"

type PeerswapServer struct {
liquidWallet wallet.Wallet
swaps *swap.SwapService
Expand Down Expand Up @@ -110,6 +112,9 @@ func NewPeerswapServer(
}

func (p *PeerswapServer) SwapOut(ctx context.Context, request *SwapOutRequest) (*SwapResponse, error) {
if request.GetAsset() == liquidAsset {
return nil, swap.ErrNewLiquidSwapsDisabled
}
if request.SwapAmount <= 0 {
return nil, errors.New("Missing required swap_amount parameter")
}
Expand Down Expand Up @@ -217,6 +222,9 @@ func (p *PeerswapServer) isPeerConnected(ctx context.Context, peerId string) boo
}

func (p *PeerswapServer) SwapIn(ctx context.Context, request *SwapInRequest) (*SwapResponse, error) {
if request.GetAsset() == liquidAsset {
return nil, swap.ErrNewLiquidSwapsDisabled
}
var swapchan *lnrpc.Channel
chans, err := p.lnd.ListChannels(ctx, &lnrpc.ListChannelsRequest{ActiveOnly: true})
if err != nil {
Expand Down
25 changes: 25 additions & 0 deletions peersync/liquid_suspension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package peersync

import (
"reflect"
"testing"
)

func TestSuspensionOmitsLiquidFromLocalCapability(t *testing.T) {
ps, _ := newTestPeerSync(t)
for _, assets := range [][]Asset{{AssetBTC, AssetLBTC}, {AssetLBTC}, {AssetBTC}} {
ps.supportedAssets = assets
capability := ps.localCapabilityForPeer(PeerID{})
for _, asset := range capability.SupportedAssets() {
if asset == AssetLBTC {
t.Fatal("advertised suspended L-BTC")
}
}
if !reflect.DeepEqual(assets, ps.supportedAssets) {
t.Fatal("changed backend assets")
}
if len(assets) == 1 && assets[0] == AssetLBTC && len(capability.SupportedAssets()) != 0 {
t.Fatal("Liquid-only node must advertise no available assets")
}
}
}
8 changes: 8 additions & 0 deletions peersync/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ func (h *messageHandler) processMessage(ctx context.Context, msg CustomMessage)
h.handlePollMessage(ctx, msg)
case messages.MESSAGETYPE_REQUEST_POLL:
h.handleRequestPollMessage(ctx, msg)
case messages.MESSAGETYPE_SWAPINREQUEST,
messages.MESSAGETYPE_SWAPOUTREQUEST,
messages.MESSAGETYPE_SWAPINAGREEMENT,
messages.MESSAGETYPE_SWAPOUTAGREEMENT,
messages.MESSAGETYPE_OPENINGTXBROADCASTED,
messages.MESSAGETYPE_CANCELED,
messages.MESSAGETYPE_COOPCLOSE:
// The swap service handles these messages independently.
default:
log.Printf("unknown message type: %v", msg.Type)
}
Expand Down
33 changes: 33 additions & 0 deletions peersync/message_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package peersync

import (
"bytes"
"context"
"log"
"testing"

"github.com/elementsproject/peerswap/messages"
)

func TestCapabilityHandlerIgnoresSwapMessages(t *testing.T) {
var output bytes.Buffer
previous := log.Writer()
log.SetOutput(&output)
t.Cleanup(func() { log.SetOutput(previous) })
h := &messageHandler{}
for _, kind := range []messages.MessageType{
messages.MESSAGETYPE_SWAPINREQUEST, messages.MESSAGETYPE_SWAPOUTREQUEST,
messages.MESSAGETYPE_SWAPINAGREEMENT, messages.MESSAGETYPE_SWAPOUTAGREEMENT,
messages.MESSAGETYPE_OPENINGTXBROADCASTED, messages.MESSAGETYPE_CANCELED,
messages.MESSAGETYPE_COOPCLOSE,
} {
h.processMessage(context.Background(), CustomMessage{Type: kind})
}
if output.Len() != 0 {
t.Fatalf("unexpected log: %s", output.String())
}
h.processMessage(context.Background(), CustomMessage{Type: 1})
if !bytes.Contains(output.Bytes(), []byte("unknown message type")) {
t.Fatal("unknown type should still be logged")
}
}
10 changes: 8 additions & 2 deletions peersync/peersync.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,14 @@ func (ps *PeerSync) Stop() error {
}

func (ps *PeerSync) localCapabilityForPeer(peer PeerID) *PeerCapability {
assets := make([]Asset, len(ps.supportedAssets))
copy(assets, ps.supportedAssets)
// Backend availability is retained for recovery, but L-BTC must not be
// advertised as available for new swaps during the suspension.
assets := make([]Asset, 0, len(ps.supportedAssets))
for _, asset := range ps.supportedAssets {
if asset != AssetLBTC {
assets = append(assets, asset)
}
}

allowed := true
if ps.guard != nil {
Expand Down
34 changes: 34 additions & 0 deletions swap/liquid_suspension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package swap

import (
"errors"
"fmt"
)

// ErrNewLiquidSwapsDisabled applies only to new swaps. Liquid services must
// remain enabled so that persisted swaps can still be recovered.
var ErrNewLiquidSwapsDisabled = errors.New("new L-BTC swaps are disabled due to the Liquid network security incident")

func (s *SwapService) rejectNewLiquidSwap(id *SwapId, peer string) error {
if id == nil {
return errors.New("missing swap id")
}
// Do not send a cancellation for a retransmission of an existing swap.
if existing, err := s.GetActiveSwap(id.String()); err == nil {
if existing.Data.PeerNodeId != peer {
return ErrReceivedMessageFromUnexpectedPeer(peer, id)
}
return AlreadyExistsError
}
payload, kind, err := MarshalPeerswapMessage(&CancelMessage{
SwapId: id,
Message: ErrNewLiquidSwapsDisabled.Error(),
})
if err != nil {
return err
}
if err := s.swapServices.messenger.SendMessage(peer, payload, kind); err != nil {
return fmt.Errorf("%w: sending cancellation: %w", ErrNewLiquidSwapsDisabled, err)
}
return ErrNewLiquidSwapsDisabled
}
Loading
Loading