Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
802afcb
staticaddr/deposit: handle loop-in htlc timeout
hieblmi Jul 1, 2026
402279a
staticaddr/deposit: ignore expiry blocks in final states
hieblmi Jul 1, 2026
d6057d3
staticaddr/deposit: reject duplicate outpoints
hieblmi Jul 1, 2026
928d1b2
staticaddr/loopin: handle closed invoice updates
hieblmi Jul 1, 2026
257d68f
staticaddr/deposit: stop removed fsms
hieblmi Jul 1, 2026
e9c8ff6
staticaddr/loopin: preserve selected deposit outpoints
hieblmi Jun 26, 2026
8cb8360
staticaddr/loopin: add lnd txout checker
hieblmi Jun 26, 2026
544bba1
staticaddr/deposit: document lock ordering
hieblmi Jul 1, 2026
c0bb49b
staticaddr/deposit: factor active deposit notifications
hieblmi Jul 1, 2026
d015edc
staticaddr/deposit: serialize deposit reconciliation
hieblmi Jul 1, 2026
c6092c8
staticaddr/deposit: reject invalid transitions
hieblmi Jul 1, 2026
67d54d9
staticaddr/loopin: default payment timeout duration
hieblmi Jul 1, 2026
f1c55b5
staticaddr/deposit: ignore queued expiry in final states
hieblmi Jul 1, 2026
2abb126
staticaddr/deposit: guard confirmation height access
hieblmi Jul 1, 2026
5320664
staticaddr/loopin: keep htlc timeout sweep resumable
hieblmi Jul 1, 2026
5c21511
staticaddr/loopin: keep htlc monitor resumable
hieblmi Jul 2, 2026
31553b0
staticaddr/deposit: canonicalize multi-deposit locks
hieblmi Jul 2, 2026
7937995
staticaddr/loopin: recover deposits by current outpoint
hieblmi Jul 2, 2026
12d11e7
staticaddr/loopin: use payment timeout duration helper
hieblmi Jul 2, 2026
d187d07
staticaddr/loopin: check deposits before htlc signing
hieblmi Jul 2, 2026
62945ad
staticaddr/loopin: scan wallet txouts once
hieblmi Jul 2, 2026
9e0cb30
staticaddr/loopin: update payment timeout comment
hieblmi Jul 2, 2026
b207508
staticaddr/openchannel: fix nolint directive
hieblmi Jul 6, 2026
9ccfb2d
build: increase lint timeout
hieblmi Jul 6, 2026
9c5ca36
staticaddr/deposit: track unconfirmed deposits
hieblmi Jul 1, 2026
b3ddee8
staticaddr: expose tracked deposit availability
hieblmi Jun 26, 2026
2c07368
staticaddr/loopin: treat unconfirmed deposits as swappable
hieblmi Jul 6, 2026
dd5b9ce
staticaddr/loopin: account for autoloop deposit expiry
hieblmi Jul 6, 2026
6372fcf
staticaddr: sync active deposits with wallet
hieblmi Jun 26, 2026
042f0c2
staticaddr/loopin: cancel signing for unavailable deposits
hieblmi Jun 26, 2026
be1e004
staticaddr/loopin: wait for risk decisions
hieblmi Jun 26, 2026
d8393a3
staticaddr/loopin: persist risk decisions
hieblmi Jun 26, 2026
f76a87c
cmd/loop: warn for low-confirmation static deposits
hieblmi Jun 26, 2026
bde6664
cmd/loop: update static loop-in replay fixtures
hieblmi Jun 26, 2026
d567271
build: increase lint timeout
hieblmi Jun 26, 2026
be55509
recover L402 and static address
hieblmi May 5, 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
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ run:
go: "1.26"

# timeout for analysis
timeout: 4m
timeout: 8m

linters:
default: all
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ To execute a Loop In:
loop in <amt_in_satoshis>
```

### Static Address Recovery
Loop now keeps at most one encrypted immutable recovery backup per paid L402
generation in the active network data directory. A backup is written only after
Loop has both the paid `l402.token` and the concrete static-address parameters
for that generation.

The backup is encrypted with a key derived by the backing `lnd` wallet. It is
therefore only useful with that same `lnd` instance, or with an `lnd` restored
from the same seed/key material. The Loop backup file alone is not enough to
recover static-address access.

Existing static-address users get this backup backfilled on the next startup
with the upgraded client. A fresh install that has no local L402 or
static-address state first checks for an existing recovery backup in the active
network directory. If none is restored, startup materializes the initial
paid-L402/static-address generation and writes its backup.

The follow-up multi-address work is expected to keep this one-backup-per-L402
model and use the deterministic receive/change key-family metadata already
stored in the backup. See [recovery/README.md](./recovery/README.md) for the
full recovery model and the planned multi-address outlook.

### More info

- [Loop FAQs](./docs/faqs.md)
Expand Down
3 changes: 2 additions & 1 deletion cmd/loop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ var (
monitorCommand, quoteCommand, listAuthCommand, fetchL402Command,
listSwapsCommand, swapInfoCommand, getLiquidityParamsCommand,
setLiquidityRuleCommand, suggestSwapCommand, setParamsCommand,
getInfoCommand, abandonSwapCommand, reservationsCommands,
getInfoCommand, abandonSwapCommand, recoverCommand,
reservationsCommands,
instantOutCommand, listInstantOutsCommand, stopCommand,
printManCommand, printMarkdownCommand,
}
Expand Down
50 changes: 50 additions & 0 deletions cmd/loop/recover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"context"

"github.com/lightninglabs/loop/looprpc"
"github.com/urfave/cli/v3"
)

var recoverCommand = &cli.Command{
Name: "recover",
Usage: "restore static address and L402 state from a local backup file",
Description: "Restores the local static-address state and L402 token " +
"from an encrypted backup file. If --backup_file is omitted, " +
"loopd selects the latest decryptable active-network backup " +
"candidate and fully validates it before restoring state.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "backup_file",
Usage: "path to an encrypted backup file; if omitted, " +
"loopd selects and validates the latest active-network " +
"backup candidate",
},
},
Action: runRecover,
}

func runRecover(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() > 0 {
return showCommandHelp(ctx, cmd)
}

client, cleanup, err := getClient(cmd)
if err != nil {
return err
}
defer cleanup()

resp, err := client.Recover(
ctx, &looprpc.RecoverRequest{
BackupFile: cmd.String("backup_file"),
},
)
if err != nil {
return err
}

printRespJSON(resp)
return nil
}
230 changes: 214 additions & 16 deletions cmd/loop/staticaddr.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"

"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/loopin"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/urfave/cli/v3"
)
Expand Down Expand Up @@ -40,13 +44,15 @@ var staticAddressCommands = &cli.Command{
var newStaticAddressCommand = &cli.Command{
Name: "new",
Aliases: []string{"n"},
Usage: "Create a new static loop in address.",
Usage: "Return the static loop in address.",
Description: `
Requests a new static loop in address from the server. Funds that are
sent to this address will be locked by a 2:2 multisig between us and the
loop server, or a timeout path that we can sweep once it opens up. The
funds can either be cooperatively spent with a signature from the server
or looped in.
Returns the current static loop in address. On a fresh installation loopd
initializes the current static-address generation during startup. If the
address is still missing, this call will create it on demand. Funds sent
to the address will be locked by a 2:2 multisig between us and the loop
server, or a timeout path that we can sweep once it opens up. The funds
can either be cooperatively spent with a signature from the server or
looped in.
`,
Action: newStaticAddress,
}
Expand All @@ -56,16 +62,16 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error {
return showCommandHelp(ctx, cmd)
}

err := displayNewAddressWarning()
client, cleanup, err := getClient(cmd)
if err != nil {
return err
}
defer cleanup()

client, cleanup, err := getClient(cmd)
err = maybeDisplayNewAddressWarning(ctx, client)
if err != nil {
return err
}
defer cleanup()

resp, err := client.NewStaticAddress(
ctx, &looprpc.NewStaticAddressRequest{},
Expand Down Expand Up @@ -553,11 +559,7 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
allDeposits := depositList.FilteredDeposits

if len(allDeposits) == 0 {
errString := fmt.Sprintf("no confirmed deposits available, "+
"deposits need at least %v confirmations",
deposit.MinConfs)

return errors.New(errString)
return errors.New("no deposited outputs available")
}

var depositOutpoints []string
Expand Down Expand Up @@ -614,6 +616,28 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
return err
}

// Warn the user if any selected deposits have fewer than 6
// confirmations, as the swap payment won't be received immediately
// for those.
summary, err := client.GetStaticAddressSummary(
ctx, &looprpc.StaticAddressSummaryRequest{},
)
if err != nil {
return err
}

depositsToCheck := warningDepositOutpoints(
allDeposits, depositOutpoints, autoSelectDepositsForQuote,
quoteReq.Amt,
)
warning := lowConfDepositWarning(
allDeposits, depositsToCheck,
int64(summary.RelativeExpiryBlocks),
)
if warning != "" {
fmt.Println(warning)
}

if !(cmd.Bool("force") || cmd.Bool("f")) {
err = displayInDetails(quoteReq, quote, cmd.Bool("verbose"))
if err != nil {
Expand Down Expand Up @@ -669,8 +693,182 @@ func depositsToOutpoints(deposits []*looprpc.Deposit) []string {
return outpoints
}

var warningSelectionDustLimit = int64(lnwallet.DustLimitForSize(input.P2TRSize))

// warningDepositOutpoints returns the deposit outpoints to check for
// low-confirmation warnings.
func warningDepositOutpoints(allDeposits []*looprpc.Deposit,
selectedOutpoints []string, autoSelect bool, targetAmount int64) []string {

if !autoSelect {
return selectedOutpoints
}

return autoSelectedWarningOutpoints(allDeposits, targetAmount)
}

// autoSelectedWarningOutpoints returns the outpoints selected by the same
// ordering used for automatic static loop-in deposit selection.
func autoSelectedWarningOutpoints(allDeposits []*looprpc.Deposit,
targetAmount int64) []string {

if targetAmount <= 0 {
return nil
}

// KEEP IN SYNC with staticaddr/loopin.SelectDeposits.
deposits := filterSwappableWarningDeposits(allDeposits)
sort.Slice(deposits, func(i, j int) bool {
iConfirmed := deposits[i].ConfirmationHeight > 0
jConfirmed := deposits[j].ConfirmationHeight > 0
if iConfirmed != jConfirmed {
return iConfirmed
}

if deposits[i].Value == deposits[j].Value {
return deposits[i].BlocksUntilExpiry <
deposits[j].BlocksUntilExpiry
}

return deposits[i].Value > deposits[j].Value
})

selectedOutpoints := make([]string, 0, len(deposits))
var selectedAmount int64
for _, deposit := range deposits {
selectedOutpoints = append(selectedOutpoints, deposit.Outpoint)
selectedAmount += deposit.Value
if selectedAmount == targetAmount {
return selectedOutpoints
}

if selectedAmount > targetAmount &&
selectedAmount-targetAmount >= warningSelectionDustLimit {

return selectedOutpoints
}
}

return nil
}

// filterSwappableWarningDeposits filters deposits for CLI warning selection.
func filterSwappableWarningDeposits(
allDeposits []*looprpc.Deposit) []*looprpc.Deposit {

swappable := make([]*looprpc.Deposit, 0, len(allDeposits))
minBlocksUntilExpiry := int64(
loopin.DefaultLoopInOnChainCltvDelta + loopin.DepositHtlcDelta,
)
for _, deposit := range allDeposits {
// Unconfirmed deposits remain swappable because their CSV timeout has
// not started yet. This mirrors loopin.IsSwappable.
if deposit.ConfirmationHeight > 0 &&
deposit.BlocksUntilExpiry < minBlocksUntilExpiry {

continue
}

swappable = append(swappable, deposit)
}

return swappable
}

// conservativeWarningConfs is the highest default confirmation tier used by
// the server's dynamic confirmation-risk policy.
//
// The CLI does not currently know the server's exact policy, so we use this
// conservative threshold for warnings without promising immediate execution.
const conservativeWarningConfs = 6

// lowConfDepositWarning checks the selected deposits against a conservative
// confirmation threshold and returns a warning string if any are found.
func lowConfDepositWarning(allDeposits []*looprpc.Deposit,
selectedOutpoints []string, csvExpiry int64) string {

depositMap := make(map[string]*looprpc.Deposit, len(allDeposits))
for _, d := range allDeposits {
depositMap[d.Outpoint] = d
}

var lowConfEntries []string
for _, op := range selectedOutpoints {
d, ok := depositMap[op]
if !ok {
continue
}

var confs int64
switch {
case d.ConfirmationHeight <= 0:
confs = 0

case csvExpiry > 0:
// For confirmed deposits we can compute
// confirmations as CSVExpiry - BlocksUntilExpiry + 1.
confs = csvExpiry - d.BlocksUntilExpiry + 1

default:
// Can't determine confirmations without the CSV expiry.
continue
}

if confs >= conservativeWarningConfs {
continue
}

if confs == 0 {
lowConfEntries = append(
lowConfEntries,
fmt.Sprintf(" - %s (unconfirmed)", op),
)
} else {
lowConfEntries = append(
lowConfEntries,
fmt.Sprintf(
" - %s (%d confirmations)", op,
confs,
),
)
}
}

if len(lowConfEntries) == 0 {
return ""
}

return fmt.Sprintf(
"\nWARNING: The following deposits are below the "+
"conservative %d-confirmation threshold:\n%s\n"+
"The swap payment for these deposits may wait for "+
"more confirmations depending on the server's "+
"confirmation-risk policy.\n",
conservativeWarningConfs,
strings.Join(lowConfEntries, "\n"),
)
}

func maybeDisplayNewAddressWarning(ctx context.Context,
client looprpc.SwapClientClient) error {

_, err := client.GetStaticAddressSummary(
ctx, &looprpc.StaticAddressSummaryRequest{},
)
switch {
case err == nil:
return nil

case strings.Contains(err.Error(), address.ErrNoStaticAddress.Error()):
return displayNewAddressWarning()

default:
return nil
}
}

func displayNewAddressWarning() error {
fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " +
fmt.Printf("\nWARNING: Be aware that losing your l402.token file in " +
".loop under your home directory will take your ability to " +
"spend funds sent to the static address via loop-ins or " +
"withdrawals. You will have to wait until the deposit " +
Expand Down
Loading
Loading