From 1fa919711f7080989eed279bdce3c149de5a7fde Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 27 May 2026 11:49:49 +0200 Subject: [PATCH 1/8] staticaddr: fix timeout comment typo --- staticaddr/script/parameters.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/staticaddr/script/parameters.go b/staticaddr/script/parameters.go index 142874e0f..89e2470b6 100644 --- a/staticaddr/script/parameters.go +++ b/staticaddr/script/parameters.go @@ -18,8 +18,8 @@ type Parameters struct { // used for the 2-of-2 funding output. ServerPubkey *btcec.PublicKey - // Expiry is the CSV timout value at which the client can claim the - // static address's timout path. + // Expiry is the CSV timeout value at which the client can claim the + // static address's timeout path. Expiry uint32 // PkScript is the unique static address's output script. From 089743dacc4f700d7c48d39975643961206130eb Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 27 May 2026 11:50:08 +0200 Subject: [PATCH 2/8] liquidity: fix comment typos --- liquidity/autoloop_test.go | 2 +- liquidity/liquidity.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/liquidity/autoloop_test.go b/liquidity/autoloop_test.go index d0b7ecc02..2093cb7c1 100644 --- a/liquidity/autoloop_test.go +++ b/liquidity/autoloop_test.go @@ -78,7 +78,7 @@ func TestAutoLoopDisabled(t *testing.T) { c.stop() } -// TestAutoLoopEnabled tests enabling the liquidity manger's autolooper. To keep +// TestAutoLoopEnabled tests enabling the liquidity manager's autolooper. To keep // the test simple, we do not update actual lnd channel balances, but rather // run our mock with two channels that will always require a loop out according // to our rules. This allows us to test the other restrictions placed on the diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 2a286a311..bcf8476f6 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -340,7 +340,7 @@ func (m *Manager) Run(ctx context.Context) error { } } - // Try to automatically dispach an asset auto-loop. + // Try to automatically dispatch an asset auto-loop. for assetID := range m.params.AssetAutoloopParams { err = m.easyAssetAutoloop(ctx, assetID) if err != nil { From 11bf5ca2190a12411ec9631af694f534aa19a6dc Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 27 May 2026 11:50:30 +0200 Subject: [PATCH 3/8] loopdb: clean up comments --- loopdb/postgres.go | 2 +- loopdb/protocol_version.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/loopdb/postgres.go b/loopdb/postgres.go index 3058b1a62..fec6dd45a 100644 --- a/loopdb/postgres.go +++ b/loopdb/postgres.go @@ -39,7 +39,7 @@ type PostgresConfig struct { RequireSSL bool `long:"requiressl" description:"Whether to require using SSL (mode: require) when connecting to the server."` } -// DSN returns the dns to connect to the database. +// DSN returns the data source name used to connect to the database. func (s *PostgresConfig) DSN(hidePassword bool) string { var sslMode = "disable" if s.RequireSSL { diff --git a/loopdb/protocol_version.go b/loopdb/protocol_version.go index 025d63acd..c98414835 100644 --- a/loopdb/protocol_version.go +++ b/loopdb/protocol_version.go @@ -24,7 +24,7 @@ const ( ProtocolVersionSegwitLoopIn ProtocolVersion = 2 // ProtocolVersionPreimagePush indicates that the client will push loop - // out preimages to the sever to speed up claim. + // out preimages to the server to speed up claim. ProtocolVersionPreimagePush ProtocolVersion = 3 // ProtocolVersionUserExpiryLoopOut indicates that the client will From c78988291cd7e1a9d189dd09d1cb6cc30e9cdb6a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 27 May 2026 11:51:09 +0200 Subject: [PATCH 4/8] loopout: fix test wording --- loopout_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/loopout_test.go b/loopout_test.go index 026d571cb..1376160a1 100644 --- a/loopout_test.go +++ b/loopout_test.go @@ -478,7 +478,7 @@ func testCustomSweepConfTarget(t *testing.T) { require.Equal(t, swap.Preimage, preimage) } - // Now that we have pushed our preimage to the sever, we send an update + // Now that we have pushed our preimage to the server, we send an update // indicating that our off chain htlc is settled. We do this so that // we don't have to keep consuming preimage pushes from our server mock // for every sweep attempt. @@ -779,22 +779,22 @@ func testPreimagePush(t *testing.T) { require.NoError(t, <-errChan) } -// TestFailedOffChainCancelation tests sending of a cancelation message to +// TestFailedOffChainCancellation tests sending of a cancellation message to // the server when a swap fails due to off-chain routing. -func TestFailedOffChainCancelation(t *testing.T) { +func TestFailedOffChainCancellation(t *testing.T) { t.Run("stable protocol", func(t *testing.T) { - testFailedOffChainCancelation(t) + testFailedOffChainCancellation(t) }) t.Run("experimental protocol", func(t *testing.T) { loopdb.EnableExperimentalProtocol() defer loopdb.ResetCurrentProtocolVersion() - testFailedOffChainCancelation(t) + testFailedOffChainCancellation(t) }) } -func testFailedOffChainCancelation(t *testing.T) { +func testFailedOffChainCancellation(t *testing.T) { defer test.Guard(t)() lnd := test.NewMockLnd() @@ -873,7 +873,7 @@ func testFailedOffChainCancelation(t *testing.T) { FailureSourceIndex: 1, }, }, - // Add one htlc that failed in the network at wide. + // Add one htlc that failed in the network at large. { Status: lnrpc.HTLCAttempt_FAILED, Route: &lnrpc.Route{ @@ -892,7 +892,7 @@ func testFailedOffChainCancelation(t *testing.T) { State: lnrpc.Payment_SUCCEEDED, } - // We want to fail our swap payment and succeed the prepush, so we send + // We want to fail our swap payment and succeed the prepayment, so we send // a failure update to the payment that has the larger amount. if pmt1.Amount > pmt2.Amount { pmt1.TrackPaymentMessage.Updates <- failUpdate @@ -908,7 +908,7 @@ func testFailedOffChainCancelation(t *testing.T) { require.NoError(t, err) payAddr := invoice.PaymentAddr.UnwrapOrFail(t) - swapCancelation := &outCancelDetails{ + swapCancellation := &outCancelDetails{ hash: swap.hash, paymentAddr: payAddr, metadata: routeCancelMetadata{ @@ -920,7 +920,7 @@ func testFailedOffChainCancelation(t *testing.T) { }, }, } - server.assertSwapCanceled(t, swapCancelation) + server.assertSwapCanceled(t, swapCancellation) // Finally, the swap should be recorded with failed off chain timeout. cfg.store.(*loopdb.StoreMock).AssertLoopOutState( From 5894637b1fcaa2668262c97bb55c5f334e0d0866 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 27 May 2026 12:19:34 +0200 Subject: [PATCH 5/8] staticaddr: reject malformed MuSig2 signing data Server-supplied nonces and partial signatures are consumed by the static address loop-in and withdrawal MuSig2 signing paths. Reject nil signing info, wrong nonce lengths, and wrong partial signature lengths before registering nonces or combining signatures, so malformed responses cannot be silently zero-padded into signing attempts. Add withdrawal coverage for nil and malformed server signing data. --- staticaddr/loopin/manager.go | 12 ++-- staticaddr/withdraw/manager.go | 21 +++++- staticaddr/withdraw/manager_test.go | 100 ++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index a41c7ced3..a76cbba39 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -379,12 +379,14 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, return err } - var ( - serverNonce [musig2.PubNonceSize]byte - sigHash [32]byte - ) + var sigHash [32]byte + + serverNonce, err := byteSliceTo66ByteSlice(nonce) + if err != nil { + return fmt.Errorf("invalid server nonce for "+ + "deposit %v: %w", depositOutpoint, err) + } - copy(serverNonce[:], nonce) musig2Session, err := staticutil.CreateMusig2Session( ctx, m.cfg.Signer, loopIn.AddressParams, loopIn.Address, ) diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 99fddd267..6cd941496 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -793,13 +793,32 @@ func (m *Manager) signMusig2Tx(ctx context.Context, // We'll now add the nonce to our session and sign the tx. for deposit, sigAndNonce := range sigInfo { + if sigAndNonce == nil { + return nil, fmt.Errorf("missing signing info for "+ + "deposit %v", deposit) + } + session, ok := sessions[deposit] if !ok { return nil, errors.New("session not found") } - nonce := [musig2.PubNonceSize]byte{} + if len(sigAndNonce.Nonce) != musig2.PubNonceSize { + return nil, fmt.Errorf("invalid nonce length for "+ + "deposit %v: got %d, want %d", deposit, + len(sigAndNonce.Nonce), musig2.PubNonceSize) + } + + if len(sigAndNonce.Sig) != input.MuSig2PartialSigSize { + return nil, fmt.Errorf("invalid partial signature "+ + "length for deposit %v: got %d, want %d", + deposit, len(sigAndNonce.Sig), + input.MuSig2PartialSigSize) + } + + var nonce [musig2.PubNonceSize]byte copy(nonce[:], sigAndNonce.Nonce) + haveAllNonces, err := signer.MuSig2RegisterNonces( ctx, session.SessionID, [][musig2.PubNonceSize]byte{nonce}, diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 4ffd4e1e8..6c883a7cc 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -377,6 +378,105 @@ func TestSignMusig2Tx_MissingOutpointInDepositMap(t *testing.T) { require.ErrorContains(t, err, "tx outpoint not in deposit index map") } +// TestSignMusig2Tx_InvalidServerSigningInfo tests that malformed server +// signing data is rejected before it is passed to the signer. +func TestSignMusig2Tx_InvalidServerSigningInfo(t *testing.T) { + t.Parallel() + + tx := wire.NewMsgTx(2) + outpoint := wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + } + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: outpoint, + }) + + pkScript := []byte{ + 0x51, 0x20, // OP_1 OP_PUSHBYTES_32 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + } + tx.AddTxOut(&wire.TxOut{ + Value: 10000, + PkScript: pkScript, + }) + + depositKey := outpoint.String() + sessions := map[string]*input.MuSig2SessionInfo{ + depositKey: { + SessionID: [32]byte{1}, + }, + } + depositsToIdx := map[string]int{ + depositKey: 0, + } + prevOutFetcher := txscript.NewMultiPrevOutFetcher( + map[wire.OutPoint]*wire.TxOut{ + outpoint: { + Value: 5000, + PkScript: pkScript, + }, + }, + ) + + validNonce := make([]byte, musig2.PubNonceSize) + validSig := make([]byte, input.MuSig2PartialSigSize) + + tests := []struct { + name string + signingInfo *swapserverrpc.ServerPsbtWithdrawSigningInfo + errContains string + }{ + { + name: "nil signing info", + signingInfo: nil, + errContains: "missing signing info", + }, + { + name: "invalid nonce length", + signingInfo: &swapserverrpc.ServerPsbtWithdrawSigningInfo{ + Nonce: validNonce[:musig2.PubNonceSize-1], + Sig: validSig, + }, + errContains: "invalid nonce length", + }, + { + name: "invalid partial signature length", + signingInfo: &swapserverrpc.ServerPsbtWithdrawSigningInfo{ + Nonce: validNonce, + Sig: validSig[:input.MuSig2PartialSigSize-1], + }, + errContains: "invalid partial signature length", + }, + } + + lnd := test.NewMockLnd() + m := &Manager{ + cfg: &ManagerConfig{ + Signer: lnd.Signer, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + sigInfo := map[string]*swapserverrpc.ServerPsbtWithdrawSigningInfo{ + depositKey: tc.signingInfo, + } + + _, err := m.signMusig2Tx( + context.Background(), prevOutFetcher, lnd.Signer, + tx.Copy(), sessions, sigInfo, depositsToIdx, + ) + require.ErrorContains(t, err, tc.errContains) + }) + } +} + // TestCalculateWithdrawalTxValues tests various edge cases in withdrawal // transaction value calculations. func TestCalculateWithdrawalTxValues(t *testing.T) { From 220abbc973a28cd6fe143eb0a537f8fb30c5f078 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 27 May 2026 12:23:36 +0200 Subject: [PATCH 6/8] sweepbatcher: reject malformed MuSig2 cosign data The cooperative batch sweep path receives a server nonce and partial signature before constructing a keyspend witness. Validate both byte slice lengths before registering the nonce or combining signatures, so malformed server responses fail explicitly instead of being zero-padded into fixed-size MuSig2 buffers. Update batcher test helpers to return size-correct placeholder signing data under the stricter validation. --- sweepbatcher/sweep_batch.go | 11 +++++++++++ sweepbatcher/sweep_batcher_test.go | 18 ++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/sweepbatcher/sweep_batch.go b/sweepbatcher/sweep_batch.go index b77024f9d..e644257f3 100644 --- a/sweepbatcher/sweep_batch.go +++ b/sweepbatcher/sweep_batch.go @@ -1882,6 +1882,17 @@ func (b *batch) musig2sign(ctx context.Context, inputIndex int, sweep sweep, return nil, err } + if len(serverNonce) != musig2.PubNonceSize { + return nil, fmt.Errorf("invalid server nonce length: got %d, "+ + "want %d", len(serverNonce), musig2.PubNonceSize) + } + + if len(serverSig) != input.MuSig2PartialSigSize { + return nil, fmt.Errorf("invalid server partial signature "+ + "length: got %d, want %d", len(serverSig), + input.MuSig2PartialSigSize) + } + var serverPublicNonce [musig2.PubNonceSize]byte copy(serverPublicNonce[:], serverNonce) diff --git a/sweepbatcher/sweep_batcher_test.go b/sweepbatcher/sweep_batcher_test.go index bfca8bb57..267483d71 100644 --- a/sweepbatcher/sweep_batcher_test.go +++ b/sweepbatcher/sweep_batcher_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -87,7 +88,12 @@ func testMuSig2SignSweep(ctx context.Context, prevoutMap map[wire.OutPoint]*wire.TxOut) ( []byte, []byte, error) { - return nil, nil, nil + return testMuSig2SigningData() +} + +func testMuSig2SigningData() ([]byte, []byte, error) { + return make([]byte, musig2.PubNonceSize), + make([]byte, input.MuSig2PartialSigSize), nil } var customSignature = func() []byte { @@ -5024,7 +5030,7 @@ func testWithMixedBatch(t *testing.T, store testStore, []byte, []byte, error) { if swapHash == swapHashes[2] { - return nil, nil, nil + return testMuSig2SigningData() } else { return nil, nil, fmt.Errorf("test error") } @@ -5377,14 +5383,14 @@ func testWithMixedBatchLarge(t *testing.T, store testStore, } else { swapHash2Used = true - return nil, nil, nil + return testMuSig2SigningData() } case swapHash == preimages[5].Hash(): - return nil, nil, nil + return testMuSig2SigningData() case swapHash == preimages[8].Hash(): - return nil, nil, nil + return testMuSig2SigningData() default: return nil, nil, fmt.Errorf("test error") @@ -5431,7 +5437,7 @@ func testWithMixedBatchCoopOnly(t *testing.T, store testStore, prevoutMap map[wire.OutPoint]*wire.TxOut) ( []byte, []byte, error) { - return nil, nil, nil + return testMuSig2SigningData() } // All the sweeps are cooperative. From b681f73f55284979ed7bd8d7a27a03f99ffc09d9 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 27 May 2026 12:27:27 +0200 Subject: [PATCH 7/8] client: reject malformed server public keys Loop-in and loop-out responses carry compressed server public keys that are copied into fixed-size fields and later used for HTLC construction. Validate the length and parse each compressed key before storing it, and validate the MuSig2 loop-in receiver internal key as well. This turns short or unparsable server keys into explicit errors instead of silently zero-padding short responses or accepting an invalid internal key. Update root test mocks to return size-correct MuSig2 signing data under the stricter checks. --- server_mock_test.go | 11 +++++++-- swap_server_client.go | 47 +++++++++++++++++++++++++++----------- swap_server_client_test.go | 26 +++++++++++++++++++++ testcontext_test.go | 2 +- 4 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 swap_server_client_test.go diff --git a/server_mock_test.go b/server_mock_test.go index 0e8c029b6..6804fa36d 100644 --- a/server_mock_test.go +++ b/server_mock_test.go @@ -6,12 +6,14 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" invpkg "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" @@ -32,6 +34,11 @@ var ( testMaxSwapAmount = btcutil.Amount(1000000) ) +func mockMuSig2SigningData() ([]byte, []byte, error) { + return make([]byte, musig2.PubNonceSize), + make([]byte, input.MuSig2PartialSigSize), nil +} + // serverMock is used in client unit tests to simulate swap server behaviour. type serverMock struct { expectedSwapAmt btcutil.Amount @@ -276,7 +283,7 @@ func (s *serverMock) MuSig2SignSweep(_ context.Context, _ loopdb.ProtocolVersion _ lntypes.Hash, _ [32]byte, _ []byte, _ []byte) ([]byte, []byte, error) { - return nil, nil, nil + return mockMuSig2SigningData() } func (s *serverMock) MultiMuSig2SignSweep(ctx context.Context, @@ -285,7 +292,7 @@ func (s *serverMock) MultiMuSig2SignSweep(ctx context.Context, prevoutMap map[wire.OutPoint]*wire.TxOut) ( []byte, []byte, error) { - return nil, nil, nil + return mockMuSig2SigningData() } func (s *serverMock) PushKey(_ context.Context, _ loopdb.ProtocolVersion, diff --git a/swap_server_client.go b/swap_server_client.go index fcec06be4..3b5e6d4f6 100644 --- a/swap_server_client.go +++ b/swap_server_client.go @@ -405,13 +405,9 @@ func (s *grpcSwapServerClient) NewLoopOutSwap(ctx context.Context, return nil, err } - var senderKey [33]byte - copy(senderKey[:], swapResp.SenderKey) - - // Validate sender key. - _, err = btcec.ParsePubKey(senderKey[:]) + senderKey, err := parseServerPubKey("sender key", swapResp.SenderKey) if err != nil { - return nil, fmt.Errorf("invalid sender key: %v", err) + return nil, err } return &newLoopOutResponse{ @@ -470,14 +466,22 @@ func (s *grpcSwapServerClient) NewLoopInSwap(ctx context.Context, return nil, err } - var receiverKey, receiverInternalKey [33]byte - copy(receiverKey[:], swapResp.ReceiverKey) - copy(receiverInternalKey[:], swapResp.ReceiverInternalPubkey) - - // Validate receiver key. - _, err = btcec.ParsePubKey(receiverKey[:]) + receiverKey, err := parseServerPubKey( + "receiver key", swapResp.ReceiverKey, + ) if err != nil { - return nil, fmt.Errorf("invalid sender key: %v", err) + return nil, err + } + + var receiverInternalKey [33]byte + if loopdb.CurrentProtocolVersion() >= loopdb.ProtocolVersionMuSig2 { + receiverInternalKey, err = parseServerPubKey( + "receiver internal key", + swapResp.ReceiverInternalPubkey, + ) + if err != nil { + return nil, err + } } return &newLoopInResponse{ @@ -488,6 +492,23 @@ func (s *grpcSwapServerClient) NewLoopInSwap(ctx context.Context, }, nil } +func parseServerPubKey(name string, keyBytes []byte) ([33]byte, error) { + if len(keyBytes) != 33 { + return [33]byte{}, fmt.Errorf("invalid %s length: got %d, "+ + "want 33", name, len(keyBytes)) + } + + var key [33]byte + copy(key[:], keyBytes) + + _, err := btcec.ParsePubKey(key[:]) + if err != nil { + return [33]byte{}, fmt.Errorf("invalid %s: %v", name, err) + } + + return key, nil +} + // ServerUpdate summarizes an update from the swap server. type ServerUpdate struct { // State is the state that the server has sent us. diff --git a/swap_server_client_test.go b/swap_server_client_test.go new file mode 100644 index 000000000..36bf035cc --- /dev/null +++ b/swap_server_client_test.go @@ -0,0 +1,26 @@ +package loop + +import ( + "testing" + + looptest "github.com/lightninglabs/loop/test" + "github.com/stretchr/testify/require" +) + +func TestParseServerPubKey(t *testing.T) { + t.Parallel() + + _, pubKey := looptest.CreateKey(1) + pubKeyBytes := pubKey.SerializeCompressed() + + parsedKey, err := parseServerPubKey("test key", pubKeyBytes) + require.NoError(t, err) + require.Equal(t, pubKeyBytes, parsedKey[:]) + + _, err = parseServerPubKey("test key", pubKeyBytes[:32]) + require.ErrorContains(t, err, "invalid test key length") + + invalidKey := make([]byte, 33) + _, err = parseServerPubKey("test key", invalidKey) + require.ErrorContains(t, err, "invalid test key") +} diff --git a/testcontext_test.go b/testcontext_test.go index e9fe20cbc..04fb6ce94 100644 --- a/testcontext_test.go +++ b/testcontext_test.go @@ -67,7 +67,7 @@ func mockMuSig2SignSweep(ctx context.Context, prevoutMap map[wire.OutPoint]*wire.TxOut) ( []byte, []byte, error) { - return nil, nil, nil + return mockMuSig2SigningData() } func newSwapClient(t *testing.T, config *clientConfig) *Client { From 5e258b6fb92f0f42a5768514db80dd01b2e31643 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 28 May 2026 17:30:10 +0200 Subject: [PATCH 8/8] looprpc: require loop:out for instant out InstantOut can now accept a caller-provided destination address and use it as the on-chain sweep target for reservation funds. That makes it an externally directed loop-out spend path, so a swap:execute-only macaroon should not be sufficient. Require the same loop:out authority used by LoopOut and add a regression test so the method cannot drift back to swap:execute-only authorization. --- looprpc/perms.go | 3 +++ looprpc/perms_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 looprpc/perms_test.go diff --git a/looprpc/perms.go b/looprpc/perms.go index d646f6671..76a448ae8 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -181,6 +181,9 @@ var RequiredPermissions = map[string][]bakery.Op{ "/looprpc.SwapClient/InstantOut": {{ Entity: "swap", Action: "execute", + }, { + Entity: "loop", + Action: "out", }}, "/looprpc.SwapClient/InstantOutQuote": {{ Entity: "swap", diff --git a/looprpc/perms_test.go b/looprpc/perms_test.go new file mode 100644 index 000000000..5d10d2cdf --- /dev/null +++ b/looprpc/perms_test.go @@ -0,0 +1,35 @@ +package looprpc + +import ( + "testing" + + "gopkg.in/macaroon-bakery.v2/bakery" +) + +func TestInstantOutRequiresLoopOutPermission(t *testing.T) { + requiredPerms, ok := RequiredPermissions["/looprpc.SwapClient/InstantOut"] + if !ok { + t.Fatalf("InstantOut permission entry missing") + } + + assertPermission := func(want bakery.Op) { + t.Helper() + + for _, perm := range requiredPerms { + if perm == want { + return + } + } + + t.Fatalf("InstantOut permission entry missing %v", want) + } + + assertPermission(bakery.Op{ + Entity: "swap", + Action: "execute", + }) + assertPermission(bakery.Op{ + Entity: "loop", + Action: "out", + }) +}