Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e340797
looprpc: fix permissions of two RPCs
starius May 7, 2026
02dc982
looprpc: fix build
starius May 7, 2026
7c3eb29
Merge pull request #1138 from starius/fix-perms
starius May 9, 2026
38481fe
go.mod: fix gonum replaces
starius May 10, 2026
6aa6979
staticaddr: validate server address parameters
hieblmi May 7, 2026
de279bc
Merge pull request #1137 from hieblmi/param-validation
hieblmi May 11, 2026
0102120
Merge pull request #1140 from starius/fix-go-mod-check
starius May 11, 2026
e92462e
staticaddr: track unconfirmed deposits
hieblmi Apr 27, 2026
1da7419
staticaddr/deposit: replay startup block to recovered deposits
hieblmi Apr 29, 2026
9d0a196
staticaddr: apply confirmation policy by flow
hieblmi Apr 27, 2026
c1295bb
cmd/loop: warn for auto-selected low-conf deposits
hieblmi Apr 27, 2026
ce8b835
staticaddr/loopin: cancel orphan invoice when init fails early
hieblmi Mar 24, 2026
a12127f
staticaddr/deposit: async finalization cleanup
hieblmi Apr 20, 2026
ac76882
staticaddr: cancel loop-ins when deposit inputs vanish
hieblmi Apr 22, 2026
80bc65d
staticaddr: harden client deposit readiness
hieblmi Apr 27, 2026
44dd32e
staticaddr/loopin: wait for risk acceptance notification
hieblmi Apr 27, 2026
a235126
staticaddr/loopin: handle risk rejection notification
hieblmi Apr 27, 2026
8128304
staticaddr/loopin: list failed swaps by state
hieblmi Apr 28, 2026
61d505f
staticaddr/loopin: persist risk decisions
hieblmi May 15, 2026
8fd491d
notifications: queue blocking fanout
hieblmi May 15, 2026
d4e3dcc
notifications: deduplicate risk fanout
hieblmi May 15, 2026
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
1 change: 1 addition & 0 deletions loopd/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
depositStore := deposit.NewSqlStore(baseDb)
depoCfg := &deposit.ManagerConfig{
AddressManager: staticAddressManager,
ChainKit: d.lnd.ChainKit,
Store: depositStore,
WalletKit: d.lnd.WalletKit,
ChainNotifier: d.lnd.ChainNotifier,
Expand Down
154 changes: 104 additions & 50 deletions loopd/swapclient_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,15 +976,24 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
return nil, fmt.Errorf("expected %d deposits, got %d",
len(req.DepositOutpoints),
len(depositList.FilteredDeposits))
} else {
numDeposits = len(depositList.FilteredDeposits)
}
numDeposits = len(depositList.FilteredDeposits)

// In case we quote for deposits, we send the server both the
// selected value and the number of deposits. This is so the
// server can probe the selected value and calculate the per
// input fee.
for _, deposit := range depositList.FilteredDeposits {
// ListStaticAddressDeposits only filters out deposits that are no
// longer visible to the user, such as Replaced records. For a manual
// quote we additionally require the current state to be Deposited so a
// stale client-side outpoint selection fails early instead of making it
// to swap initiation.
if deposit.State != looprpc.DepositState_DEPOSITED {
return nil, fmt.Errorf("deposit %s is not "+
"currently available", deposit.Outpoint)
}

totalDepositAmount += btcutil.Amount(
deposit.Value,
)
Expand Down Expand Up @@ -1662,54 +1671,43 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context,
// not spendable because they already have been used but not yet spent
// by the server. We filter out such deposits here.
var (
outpoints []string
isUnspent = make(map[wire.OutPoint]struct{})
outpoints []string
isUnspent = make(map[wire.OutPoint]struct{})
knownUtxos = make(map[wire.OutPoint]struct{})
)

// Keep track of confirmed outpoints that we need to check against our
// database.
confirmedToCheck := make(map[wire.OutPoint]struct{})

for _, utxo := range utxos {
if utxo.Confirmations < deposit.MinConfs {
// Unconfirmed deposits are always available.
isUnspent[utxo.OutPoint] = struct{}{}
} else {
// Confirmed deposits need to be checked.
outpoints = append(outpoints, utxo.OutPoint.String())
confirmedToCheck[utxo.OutPoint] = struct{}{}
}
outpoints = append(outpoints, utxo.OutPoint.String())
knownUtxos[utxo.OutPoint] = struct{}{}
}

// Check the spent status of the deposits by looking at their states.
ignoreUnknownOutpoints := false
ignoreUnknownOutpoints := true
deposits, err := s.depositManager.DepositsForOutpoints(
ctx, outpoints, ignoreUnknownOutpoints,
)
if err != nil {
return nil, err
}

knownDeposits := make(map[wire.OutPoint]struct{}, len(deposits))
for _, d := range deposits {
// A nil deposit means we don't have a record for it. We'll
// handle this case after the loop.
if d == nil {
continue
}

// If the deposit is in the "Deposited" state, it's available.
knownDeposits[d.OutPoint] = struct{}{}
if d.IsInState(deposit.Deposited) {
isUnspent[d.OutPoint] = struct{}{}
}

// We have a record for this deposit, so we no longer need to
// check it.
delete(confirmedToCheck, d.OutPoint)
}

// Any remaining outpoints in confirmedToCheck are ones that lnd knows
// about but we don't. These are new, unspent deposits.
for op := range confirmedToCheck {
isUnspent[op] = struct{}{}
// Any wallet outpoints that are unknown to the deposit store are new
// deposits and therefore still available.
for op := range knownUtxos {
if _, ok := knownDeposits[op]; !ok {
isUnspent[op] = struct{}{}
}
}

// Prepare the list of unspent deposits for the rpc response.
Expand Down Expand Up @@ -1756,8 +1754,9 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
return nil, err
}

for _, d := range deposits {
outpoints = append(outpoints, d.OutPoint)
outpoints, err = withdrawAllDepositOutpoints(deposits)
if err != nil {
return nil, err
}

case isUtxoSelected:
Expand All @@ -1780,6 +1779,25 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
}, err
}

// withdrawAllDepositOutpoints returns all deposit outpoints for an `all`
// withdrawal request. The request must fail if any deposited output is still
// unconfirmed because `all` should not silently downgrade to a subset.
func withdrawAllDepositOutpoints(deposits []*deposit.Deposit) ([]wire.OutPoint,
error) {

outpoints := make([]wire.OutPoint, 0, len(deposits))
for _, d := range deposits {
if d.ConfirmationHeight <= 0 {
return nil, fmt.Errorf("can't withdraw all deposits while " +
"some deposits are unconfirmed")
}

outpoints = append(outpoints, d.OutPoint)
}

return outpoints, nil
}

// ListStaticAddressDeposits returns a list of all sufficiently confirmed
// deposits behind the static address and displays properties like value,
// state or blocks til expiry.
Expand All @@ -1804,7 +1822,8 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
var filteredDeposits []*looprpc.Deposit
if len(outpoints) > 0 {
f := func(d *deposit.Deposit) bool {
return slices.Contains(outpoints, d.OutPoint.String())
return isVisibleDeposit(d) &&
slices.Contains(outpoints, d.OutPoint.String())
}
filteredDeposits = filter(allDeposits, f)

Expand All @@ -1814,6 +1833,10 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
}
} else {
f := func(d *deposit.Deposit) bool {
if !isVisibleDeposit(d) {
return false
}

if req.StateFilter == looprpc.DepositState_UNKNOWN_STATE {
// Per default, we return deposits in all
// states.
Expand Down Expand Up @@ -1948,9 +1971,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
protoDeposits = make([]*looprpc.Deposit, 0, len(ds))
for _, d := range ds {
state := toClientDepositState(d.GetState())
blocksUntilExpiry := d.ConfirmationHeight +
int64(addrParams.Expiry) -
int64(lndInfo.BlockHeight)
blocksUntilExpiry := depositBlocksUntilExpiry(
d.ConfirmationHeight, addrParams.Expiry,
int64(lndInfo.BlockHeight),
)

pd := &looprpc.Deposit{
Id: d.ID[:],
Expand Down Expand Up @@ -1999,6 +2023,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
if err != nil {
return nil, err
}
allDeposits = filterDeposits(allDeposits, isVisibleDeposit)

var (
totalNumDeposits = len(allDeposits)
Expand All @@ -2011,23 +2036,16 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
htlcTimeoutSwept int64
)

// Value unconfirmed.
utxos, err := s.staticAddressManager.ListUnspent(
ctx, 0, deposit.MinConfs-1,
)
if err != nil {
return nil, err
}
for _, u := range utxos {
valueUnconfirmed += int64(u.Value)
}

// Confirmed total values by category.
// Total values by category.
for _, d := range allDeposits {
value := int64(d.Value)
switch d.GetState() {
case deposit.Deposited:
valueDeposited += value
if d.ConfirmationHeight <= 0 {
valueUnconfirmed += value
} else {
valueDeposited += value
}

case deposit.Expired:
valueExpired += value
Expand Down Expand Up @@ -2170,13 +2188,27 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context,
return err
}
for i := range len(deposits) {
deposits[i].BlocksUntilExpiry =
deposits[i].ConfirmationHeight +
int64(params.Expiry) - bestBlockHeight
deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry(
deposits[i].ConfirmationHeight, params.Expiry,
bestBlockHeight,
)
}
return nil
}

// depositBlocksUntilExpiry returns the remaining blocks until a deposit
// expires. Unconfirmed deposits return the full CSV value because the timeout
// has not started yet.
func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32,
bestBlockHeight int64) int64 {

if confirmationHeight <= 0 {
return int64(expiry)
}

return confirmationHeight + int64(expiry) - bestBlockHeight
}

// StaticOpenChannel initiates an open channel request using static address
// deposits.
func (s *swapClientServer) StaticOpenChannel(ctx context.Context,
Expand Down Expand Up @@ -2206,6 +2238,28 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context,

type filterFunc func(deposits *deposit.Deposit) bool

func filterDeposits(deposits []*deposit.Deposit,
f filterFunc) []*deposit.Deposit {

filtered := make([]*deposit.Deposit, 0, len(deposits))
for _, deposit := range deposits {
if !f(deposit) {
continue
}

filtered = append(filtered, deposit)
}

return filtered
}

func isVisibleDeposit(d *deposit.Deposit) bool {
// Replaced deposits are kept in the DB as history, but they should disappear
// from normal deposit listings and summary totals because the underlying
// outpoint is no longer present in the wallet and cannot be spent.
return d.GetState() != deposit.Replaced
}

func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit {
var clientDeposits []*looprpc.Deposit
for _, d := range deposits {
Expand Down
86 changes: 86 additions & 0 deletions loopd/swapclient_server_deposit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package loopd

import (
"testing"

"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/staticaddr/deposit"
)

// TestDepositBlocksUntilExpiry checks blocks-until-expiry handling for
// confirmed and unconfirmed deposits.
func TestDepositBlocksUntilExpiry(t *testing.T) {
t.Run("unconfirmed", func(t *testing.T) {
if blocks := depositBlocksUntilExpiry(0, 144, 500); blocks != 144 {
t.Fatalf("expected 144 blocks for unconfirmed deposit, got %d",
blocks)
}
})

t.Run("confirmed", func(t *testing.T) {
if blocks := depositBlocksUntilExpiry(450, 144, 500); blocks != 94 {
t.Fatalf("expected 94 blocks until expiry, got %d",
blocks)
}
})
}

// TestWithdrawAllDepositOutpoints checks `all` withdrawal handling for
// confirmed and unconfirmed deposits.
func TestWithdrawAllDepositOutpoints(t *testing.T) {
t.Run("rejects unconfirmed", func(t *testing.T) {
deposits := []*deposit.Deposit{
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
},
},
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
},
ConfirmationHeight: 123,
},
}

_, err := withdrawAllDepositOutpoints(deposits)
if err == nil {
t.Fatal("expected unconfirmed deposit to fail all withdrawal")
}
})

t.Run("returns all confirmed", func(t *testing.T) {
first := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 3,
}
second := wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 4,
}
deposits := []*deposit.Deposit{
{
OutPoint: first,
ConfirmationHeight: 123,
},
{
OutPoint: second,
ConfirmationHeight: 124,
},
}

outpoints, err := withdrawAllDepositOutpoints(deposits)
if err != nil {
t.Fatalf("expected confirmed deposits to succeed: %v", err)
}
if len(outpoints) != 2 {
t.Fatalf("expected 2 outpoints, got %d", len(outpoints))
}
if outpoints[0] != first || outpoints[1] != second {
t.Fatal("expected all confirmed outpoints to remain selected")
}
})
}
Loading