From 508b9dfdc1e95f2dd7f31fb7cf516a8f1daa0f5d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 11 May 2026 15:28:26 +0200 Subject: [PATCH] multi: require Loop Out permission for Instant Out The InstantOut RPC accepts a caller-controlled dest_addr that becomes the output of the cooperative sweepless sweep (and of the htlc success sweep on the fallback path), so it is a fund-moving operation equivalent to LoopOut. Until now it required only swap:execute, while LoopOut requires both swap:execute and loop:out. A macaroon scoped to swap:execute -- intended for, say, an autoloop scheduler or a quote poller -- could therefore drain reservation balances to an attacker address. ReservationRequest is analogous on the inbound side: it triggers an outgoing LN prepayment, so it also belongs behind loop:out. We also harden the address handling in instantout.Manager.NewInstantOut to match validateLoopOutRequest: - sweepAddr.IsForNet(m.cfg.Network) is now enforced. btcutil .DecodeAddress is more permissive than IsForNet for some formats (notably anything that happens to share a network prefix); without the explicit network check cross-chain copy-paste mistakes parse silently and then sign over an unspendable output. - The address must be one of the formats Loop normally accepts: P2TR / P2WSH / P2WPKH / P2SH / P2PKH. Anything else (e.g. a future address type that the user's wallet would otherwise interpret differently) is rejected up front rather than failing later in the signing path. InstantOutQuote and ReservationQuote stay on swap:read since they are read-only. --- instantout/manager.go | 18 ++++++++++++++++++ looprpc/perms.go | 3 +++ 2 files changed, 21 insertions(+) diff --git a/instantout/manager.go b/instantout/manager.go index bebd8f82..e96791e3 100644 --- a/instantout/manager.go +++ b/instantout/manager.go @@ -156,6 +156,24 @@ func (m *Manager) NewInstantOut(ctx context.Context, if err != nil { return nil, err } + + if !sweepAddr.IsForNet(m.cfg.Network) { + return nil, fmt.Errorf("sweep address %s is not "+ + "valid for network %s", sweepAddress, + m.cfg.Network.Name) + } + + switch sweepAddr.(type) { + case *btcutil.AddressTaproot, + *btcutil.AddressWitnessScriptHash, + *btcutil.AddressWitnessPubKeyHash, + *btcutil.AddressScriptHash, + *btcutil.AddressPubKeyHash: + + default: + return nil, fmt.Errorf("unsupported sweep address "+ + "type %T", sweepAddr) + } } m.Lock() diff --git a/looprpc/perms.go b/looprpc/perms.go index 4a438914..eb793687 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -184,6 +184,9 @@ var RequiredPermissions = map[string][]bakery.Op{ "/looprpc.SwapClient/ReservationRequest": {{ Entity: "swap", Action: "execute", + }, { + Entity: "loop", + Action: "out", }}, "/looprpc.SwapClient/ReservationQuote": {{ Entity: "swap",