Skip to content
Open
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
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 @@ -88,7 +88,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
}
223 changes: 208 additions & 15 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"
lndcommands "github.com/lightningnetwork/lnd/cmd/commands"
"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,14 @@ 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("no deposited outputs available")
}

return errors.New(errString)
summary, err := client.GetStaticAddressSummary(
ctx, &looprpc.StaticAddressSummaryRequest{},
)
if err != nil {
return err
}

var depositOutpoints []string
Expand Down Expand Up @@ -614,6 +623,21 @@ 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.
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,177 @@ func depositsToOutpoints(deposits []*looprpc.Deposit) []string {
return outpoints
}

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

func warningDepositOutpoints(allDeposits []*looprpc.Deposit,
selectedOutpoints []string, autoSelect bool, targetAmount int64) []string {

if !autoSelect {
return selectedOutpoints
}

return autoSelectedWarningOutpoints(allDeposits, targetAmount)
}

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
}

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