diff --git a/README.md b/README.md index 0bba28ef8..9c38efeea 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,28 @@ To execute a Loop In: loop in ``` +### 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) diff --git a/cmd/loop/main.go b/cmd/loop/main.go index 3f82be775..09360eb8e 100644 --- a/cmd/loop/main.go +++ b/cmd/loop/main.go @@ -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, } diff --git a/cmd/loop/recover.go b/cmd/loop/recover.go new file mode 100644 index 000000000..18d43a7d8 --- /dev/null +++ b/cmd/loop/recover.go @@ -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 +} diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index fc36597e4..1ffcb2ff3 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -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" ) @@ -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, } @@ -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{}, @@ -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 @@ -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 { @@ -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 " + diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go new file mode 100644 index 000000000..97863da3b --- /dev/null +++ b/cmd/loop/staticaddr_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "strings" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/stretchr/testify/require" +) + +func TestLowConfDepositWarningConfirmedOnly(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "confirmed-low", + ConfirmationHeight: 100, + BlocksUntilExpiry: 140, + }, + { + Outpoint: "confirmed-high", + ConfirmationHeight: 95, + BlocksUntilExpiry: 139, + }, + } + + warning := lowConfDepositWarning( + deposits, []string{"confirmed-low", "confirmed-high"}, 144, + ) + + require.Contains(t, warning, "confirmed-low (5 confirmations)") + require.NotContains(t, warning, "confirmed-high") +} + +func TestLowConfDepositWarningUnconfirmed(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "mempool", + ConfirmationHeight: 0, + BlocksUntilExpiry: 144, + }, + } + + warning := lowConfDepositWarning(deposits, []string{"mempool"}, 144) + + require.Contains(t, warning, "mempool (unconfirmed)") + require.True( + t, + strings.Contains( + warning, + "conservative 6-confirmation threshold", + ), + ) + require.NotContains(t, warning, "executed immediately") +} + +func TestWarningDepositOutpointsAutoSelectPrefersConfirmed(t *testing.T) { + t.Parallel() + + const csvExpiry = 1100 + + deposits := []*looprpc.Deposit{ + { + Outpoint: "mempool-large", + Value: 2_000_000, + ConfirmationHeight: 0, + BlocksUntilExpiry: csvExpiry, + }, + { + Outpoint: "confirmed", + Value: 1_500_000, + ConfirmationHeight: 100, + BlocksUntilExpiry: csvExpiry - 5, + }, + } + + selected := warningDepositOutpoints(deposits, nil, true, 1_000_000) + + require.Equal(t, []string{"confirmed"}, selected) + require.Empty(t, lowConfDepositWarning(deposits, selected, csvExpiry)) +} + +func TestWarningDepositOutpointsAutoSelectIncludesNeededUnconfirmed(t *testing.T) { + t.Parallel() + + const csvExpiry = 1100 + + deposits := []*looprpc.Deposit{ + { + Outpoint: "confirmed-small", + Value: 500_000, + ConfirmationHeight: 100, + BlocksUntilExpiry: csvExpiry - 5, + }, + { + Outpoint: "mempool-large", + Value: 2_000_000, + ConfirmationHeight: 0, + BlocksUntilExpiry: csvExpiry, + }, + } + + selected := warningDepositOutpoints(deposits, nil, true, 1_000_000) + + require.Equal( + t, []string{"confirmed-small", "mempool-large"}, selected, + ) + + warning := lowConfDepositWarning(deposits, selected, csvExpiry) + require.Contains(t, warning, "mempool-large (unconfirmed)") + require.NotContains(t, warning, "confirmed-small") +} + +func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { + t.Parallel() + + const ( + blockHeight = uint32(10_000) + csvExpiry = uint32(1_200) + targetAmount = int64(2_500_000) + ) + + type fixture struct { + name string + value int64 + confirmationHeight int64 + } + + fixtures := []fixture{ + { + name: "mempool-huge", + value: 3_000_000, + confirmationHeight: 0, + }, + { + name: "confirmed-later-expiry", + value: 2_000_000, + confirmationHeight: 9_900, + }, + { + name: "confirmed-earlier-expiry", + value: 2_000_000, + confirmationHeight: 9_890, + }, + { + name: "confirmed-small", + value: 600_000, + confirmationHeight: 9_900, + }, + { + name: "confirmed-too-close-to-expiry", + value: 5_000_000, + confirmationHeight: 9_849, + }, + } + + rpcDeposits := make([]*looprpc.Deposit, 0, len(fixtures)) + loopInDeposits := make([]*deposit.Deposit, 0, len(fixtures)) + for idx, fixture := range fixtures { + hash := chainhash.Hash{byte(idx + 1)} + outpoint := wire.OutPoint{ + Hash: hash, + Index: uint32(idx), + } + + blocksUntilExpiry := int64(0) + if fixture.confirmationHeight > 0 { + blocksUntilExpiry = fixture.confirmationHeight + + int64(csvExpiry) - int64(blockHeight) + } + + rpcDeposits = append(rpcDeposits, &looprpc.Deposit{ + Outpoint: outpoint.String(), + Value: fixture.value, + ConfirmationHeight: fixture.confirmationHeight, + BlocksUntilExpiry: blocksUntilExpiry, + }) + loopInDeposits = append(loopInDeposits, &deposit.Deposit{ + OutPoint: outpoint, + Value: btcutil.Amount(fixture.value), + ConfirmationHeight: fixture.confirmationHeight, + }) + } + + cliSelected := autoSelectedWarningOutpoints( + rpcDeposits, targetAmount, + ) + + loopInSelected, err := loopin.SelectDeposits( + btcutil.Amount(targetAmount), loopInDeposits, csvExpiry, + blockHeight, + ) + require.NoError(t, err) + + loopInSelectedOutpoints := make([]string, 0, len(loopInSelected)) + for _, selected := range loopInSelected { + loopInSelectedOutpoints = append( + loopInSelectedOutpoints, selected.OutPoint.String(), + ) + } + + require.Equal(t, loopInSelectedOutpoints, cliSelected) +} diff --git a/docs/loop.1 b/docs/loop.1 index 7d767cdff..645390d1e 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -403,6 +403,16 @@ abandon a swap with a given swap hash .PP \fB--i_know_what_i_am_doing\fP: Specify this flag if you made sure that you read and understood the following consequence of applying this command. +.SH recover +.PP +restore static address and L402 state from a local backup file + +.PP +\fB--backup_file\fP="": path to an encrypted backup file; if omitted, loopd selects and validates the latest active-network backup candidate + +.PP +\fB--help, -h\fP: show help + .SH reservations, r .PP manage reservations @@ -456,7 +466,7 @@ perform on-chain to off-chain swaps using static addresses. .SS new, n .PP -Create a new static loop in address. +Return the static loop in address. .PP \fB--help, -h\fP: show help diff --git a/docs/loop.md b/docs/loop.md index 3a847e96c..a71df2cc4 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -418,6 +418,25 @@ The following flags are supported: | `--i_know_what_i_am_doing` | Specify this flag if you made sure that you read and understood the following consequence of applying this command | bool | `false` | | `--help` (`-h`) | show help | bool | `false` | +### `recover` command + +restore static address and L402 state from a local backup file. + +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. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] recover [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|---------------------|----------------------------------------------------------------------------------------------------------------------|--------|:-------------:| +| `--backup_file="…"` | path to an encrypted backup file; if omitted, loopd selects and validates the latest active-network backup candidate | string | +| `--help` (`-h`) | show help | bool | `false` | + ### `reservations` command (aliases: `r`) manage reservations. @@ -529,9 +548,9 @@ The following flags are supported: ### `static new` subcommand (aliases: `n`) -Create a new static loop in address. +Return the static loop in address. -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. Usage: diff --git a/go.mod b/go.mod index 259221452..0b0ba5a02 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/urfave/cli-docs/v3 v3.1.1-0.20251020101624-bec07369b4f6 github.com/urfave/cli/v3 v3.4.1 go.etcd.io/bbolt v1.4.3 + golang.org/x/crypto v0.46.0 golang.org/x/sync v0.19.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 @@ -194,7 +195,6 @@ require ( go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/loopd/daemon.go b/loopd/daemon.go index 880e19621..28b5cdb80 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/loop/loopdb" loop_looprpc "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/notifications" + "github.com/lightninglabs/loop/recovery" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" @@ -577,13 +578,16 @@ func (d *Daemon) initialize(withMacaroonService bool) error { withdrawalManager *withdraw.Manager openChannelManager *openchannel.Manager staticLoopInManager *loopin.Manager + recoveryService *recovery.Service ) // Static address manager setup. staticAddressStore := address.NewSqlStore(baseDb) addrCfg := &address.ManagerConfig{ AddressClient: staticAddressClient, - FetchL402: swapClient.Server.FetchL402, + FetchL402: func(ctx context.Context) error { + return swapClient.Server.FetchL402(ctx) + }, Store: staticAddressStore, WalletKit: d.lnd.WalletKit, ChainParams: d.lnd.ChainParams, @@ -601,6 +605,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, @@ -678,6 +683,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { Store: staticAddressLoopInStore, WalletKit: d.lnd.WalletKit, ChainNotifier: d.lnd.ChainNotifier, + TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client), NotificationManager: notificationManager, ChainParams: d.lnd.ChainParams, Signer: d.lnd.Signer, @@ -689,6 +695,46 @@ func (d *Daemon) initialize(withMacaroonService bool) error { return fmt.Errorf("unable to create loop-in manager: %w", err) } + // Keep startup restore/write-backup free of deposit reconciliation so we + // don't create deposit FSMs before the deposit manager is running. + startupRecoveryService := recovery.NewService( + d.cfg.DataDir, d.cfg.Network, d.lnd.Signer, d.lnd.WalletKit, + staticAddressManager, nil, + ) + + restoreResult, restoredFromBackup, err := + startupRecoveryService.RestoreLatestOnFreshInstall(d.mainCtx) + if err != nil { + return fmt.Errorf("unable to restore latest recovery "+ + "backup on fresh install: %w", err) + } + if restoredFromBackup { + infof("Restored fresh install from encrypted recovery "+ + "backup %s", restoreResult.BackupFile) + } else { + _, _, err = staticAddressManager.NewAddress(d.mainCtx) + if err != nil { + warnf("Unable to initialize static address generation "+ + "during startup: %v", err) + } + } + + backupFile, err := startupRecoveryService.WriteBackup(d.mainCtx) + if err != nil { + warnf("Unable to write startup recovery backup: %v", err) + } + if backupFile != "" { + infof("Wrote encrypted recovery backup to %s after "+ + "initializing the current L402 generation", backupFile) + } + + // Runtime recovery is wired with the deposit manager so explicit + // recovery RPCs can reconcile restored static-address deposits. + recoveryService = recovery.NewService( + d.cfg.DataDir, d.cfg.Network, d.lnd.Signer, d.lnd.WalletKit, + staticAddressManager, depositManager, + ) + var ( reservationManager *reservation.Manager instantOutManager *instantout.Manager @@ -753,6 +799,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { staticLoopInManager: staticLoopInManager, openChannelManager: openChannelManager, assetClient: d.assetClient, + recoveryService: recoveryService, stopDaemon: d.Stop, } diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 62187d3e5..19320d1b5 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -29,6 +29,7 @@ import ( "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/recovery" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" @@ -101,6 +102,7 @@ type swapClientServer struct { staticLoopInManager *loopin.Manager openChannelManager *openchannel.Manager assetClient *assets.TapdClient + recoveryService *recovery.Service swaps map[lntypes.Hash]loop.SwapInfo subscribers map[int]chan<- any statusChan chan loop.SwapInfo @@ -976,15 +978,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, ) @@ -1268,6 +1279,32 @@ func (s *swapClientServer) FetchL402Token(ctx context.Context, return &looprpc.FetchL402TokenResponse{}, nil } +// Recover restores the local paid L402 token material and static-address state +// from an encrypted backup file. +func (s *swapClientServer) Recover(ctx context.Context, + req *looprpc.RecoverRequest) (*looprpc.RecoverResponse, error) { + + if s.recoveryService == nil { + return nil, status.Error( + codes.Unavailable, "recovery service not configured", + ) + } + + result, err := s.recoveryService.Restore(ctx, req.GetBackupFile()) + if err != nil { + return nil, err + } + + return &looprpc.RecoverResponse{ + BackupFile: result.BackupFile, + RestoredL402: result.RestoredL402, + RestoredStaticAddress: result.RestoredStaticAddress, + StaticAddress: result.StaticAddress, + NumDepositsFound: uint32(result.NumDepositsFound), + DepositReconciliationError: result.DepositReconciliationError, + }, nil +} + // GetInfo returns basic information about the loop daemon and details to swaps // from the swap store. func (s *swapClientServer) GetInfo(ctx context.Context, @@ -1637,6 +1674,17 @@ func (s *swapClientServer) NewStaticAddress(ctx context.Context, return nil, err } + if s.recoveryService != nil { + backupFile, backupErr := s.recoveryService.WriteBackup(ctx) + if backupErr != nil { + warnf("Unable to write recovery backup after static "+ + "address request: %v", backupErr) + } else if backupFile != "" { + infof("Wrote encrypted recovery backup to %s after "+ + "static address request", backupFile) + } + } + return &looprpc.NewStaticAddressResponse{ Address: staticAddress.String(), Expiry: uint32(expiry), @@ -1658,58 +1706,35 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, } // ListUnspentRaw returns the unspent wallet view of the backing lnd - // wallet. It might be that deposits show up there that are actually - // not spendable because they already have been used but not yet spent - // by the server. We filter out such deposits here. + // wallet. Static loop-in initiation requires an active deposit record, + // so only deposits that are both wallet-visible and tracked as + // Deposited are returned here. var ( outpoints []string isUnspent = 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()) } // 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 } + 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. 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{}{} } // Prepare the list of unspent deposits for the rpc response. @@ -1756,8 +1781,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: @@ -1780,6 +1806,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. @@ -1804,7 +1849,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) @@ -1814,6 +1860,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. @@ -1948,9 +1998,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[:], @@ -1999,6 +2050,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, if err != nil { return nil, err } + allDeposits = filterDeposits(allDeposits, isVisibleDeposit) var ( totalNumDeposits = len(allDeposits) @@ -2011,23 +2063,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 @@ -2170,13 +2215,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, @@ -2206,6 +2265,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 { diff --git a/loopd/swapclient_server_deposit_test.go b/loopd/swapclient_server_deposit_test.go new file mode 100644 index 000000000..3d4ce065a --- /dev/null +++ b/loopd/swapclient_server_deposit_test.go @@ -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") + } + }) +} diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go new file mode 100644 index 000000000..81c741926 --- /dev/null +++ b/loopd/swapclient_server_staticaddr_test.go @@ -0,0 +1,211 @@ +package loopd + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + mock_lnd "github.com/lightninglabs/loop/test" + "github.com/stretchr/testify/require" +) + +type staticAddrDepositStore struct { + allDeposits []*deposit.Deposit + byOutpoint map[string]*deposit.Deposit +} + +func (s *staticAddrDepositStore) CreateDeposit(context.Context, + *deposit.Deposit) error { + + return nil +} + +func (s *staticAddrDepositStore) UpdateDeposit(context.Context, + *deposit.Deposit) error { + + return nil +} + +func (s *staticAddrDepositStore) GetDeposit(context.Context, + deposit.ID) (*deposit.Deposit, error) { + + return nil, nil +} + +func (s *staticAddrDepositStore) DepositForOutpoint(_ context.Context, + outpoint string) (*deposit.Deposit, error) { + + if deposit, ok := s.byOutpoint[outpoint]; ok { + return deposit, nil + } + + return nil, deposit.ErrDepositNotFound +} + +func (s *staticAddrDepositStore) AllDeposits(context.Context) ( + []*deposit.Deposit, error) { + + return s.allDeposits, nil +} + +func newTestDepositManager( + deposits ...*deposit.Deposit) *deposit.Manager { + + byOutpoint := make(map[string]*deposit.Deposit, len(deposits)) + for _, deposit := range deposits { + byOutpoint[deposit.OutPoint.String()] = deposit + } + + return deposit.NewManager(&deposit.ManagerConfig{ + Store: &staticAddrDepositStore{ + allDeposits: deposits, + byOutpoint: byOutpoint, + }, + }) +} + +func newTestStaticAddressContext(t *testing.T) (*address.Manager, + *mock_lnd.LndMockServices) { + + t.Helper() + + mock := mock_lnd.NewMockLnd() + _, client := mock_lnd.CreateKey(1) + _, server := mock_lnd.CreateKey(2) + + addrStore := &mockAddressStore{ + params: []*address.Parameters{{ + ClientPubkey: client, + ServerPubkey: server, + Expiry: 10, + PkScript: []byte("pkscript"), + }}, + } + + addrMgr, err := address.NewManager(&address.ManagerConfig{ + Store: addrStore, + WalletKit: mock.WalletKit, + ChainParams: mock.ChainParams, + }, 1) + require.NoError(t, err) + + return addrMgr, mock +} + +func TestListStaticAddressDepositsHidesReplaced(t *testing.T) { + t.Parallel() + + replaced := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + }, + } + replaced.SetState(deposit.Replaced) + + available := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + } + available.SetState(deposit.Deposited) + + addrMgr, lnd := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager(replaced, available), + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + + resp, err := server.ListStaticAddressDeposits( + context.Background(), &looprpc.ListStaticAddressDepositsRequest{}, + ) + require.NoError(t, err) + require.Len(t, resp.FilteredDeposits, 1) + require.Equal( + t, available.OutPoint.String(), + resp.FilteredDeposits[0].Outpoint, + ) +} + +func TestGetStaticAddressSummaryIgnoresReplaced(t *testing.T) { + t.Parallel() + + replaced := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 3, + }, + Value: btcutil.Amount(1_000), + } + replaced.SetState(deposit.Replaced) + + unconfirmed := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 4, + }, + Value: btcutil.Amount(2_000), + ConfirmationHeight: 0, + } + unconfirmed.SetState(deposit.Deposited) + + confirmed := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{5}, + Index: 5, + }, + Value: btcutil.Amount(3_000), + ConfirmationHeight: 123, + } + confirmed.SetState(deposit.Deposited) + + addrMgr, _ := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager( + replaced, unconfirmed, confirmed, + ), + staticAddressManager: addrMgr, + } + + resp, err := server.GetStaticAddressSummary( + context.Background(), &looprpc.StaticAddressSummaryRequest{}, + ) + require.NoError(t, err) + require.EqualValues(t, 2, resp.TotalNumDeposits) + require.EqualValues(t, 2_000, resp.ValueUnconfirmedSatoshis) + require.EqualValues(t, 3_000, resp.ValueDepositedSatoshis) +} + +func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) { + t.Parallel() + setLogger(btclog.Disabled) + + locked := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{6}, + Index: 6, + }, + Value: btcutil.Amount(5_000), + } + locked.SetState(deposit.LoopingIn) + + addrMgr, lnd := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager(locked), + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + + _, err := server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{ + DepositOutpoints: []string{locked.OutPoint.String()}, + }) + require.ErrorContains(t, err, "is not currently available") +} diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index a3f29443f..7f57be5cc 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -1002,7 +1002,7 @@ func (s *mockDepositStore) DepositForOutpoint(_ context.Context, if d, ok := s.byOutpoint[outpoint]; ok { return d, nil } - return nil, nil + return nil, deposit.ErrDepositNotFound } func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit, error) { @@ -1051,11 +1051,11 @@ func TestListUnspentDeposits(t *testing.T) { } } - minConfs := int64(deposit.MinConfs) - utxoBelow := makeUtxo(0, minConfs-1) // always included - utxoAt := makeUtxo(1, minConfs) // included only if Deposited - utxoAbove1 := makeUtxo(2, minConfs+1) - utxoAbove2 := makeUtxo(3, minConfs+2) + utxoUnknown := makeUtxo(0, 0) + utxoDeposited := makeUtxo(1, 1) + utxoWithdrawn := makeUtxo(2, 2) + utxoLoopingIn := makeUtxo(3, 5) + utxoConfirmedUnknown := makeUtxo(4, 3) // Helper to build the deposit manager with specific states. buildDepositMgr := func( @@ -1073,17 +1073,19 @@ func TestListUnspentDeposits(t *testing.T) { return deposit.NewManager(&deposit.ManagerConfig{Store: store}) } - // Include below-min-conf and >=min with Deposited; exclude others. - t.Run("below min conf always, Deposited included, others excluded", + // Only known Deposited records are available. Unknown deposits and + // known non-Deposited states are excluded. + t.Run("only known Deposited included", func(t *testing.T) { mock.SetListUnspent([]*lnwallet.Utxo{ - utxoBelow, utxoAt, utxoAbove1, utxoAbove2, + utxoUnknown, utxoDeposited, utxoWithdrawn, + utxoLoopingIn, }) depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ - utxoAt.OutPoint: deposit.Deposited, - utxoAbove1.OutPoint: deposit.Withdrawn, - utxoAbove2.OutPoint: deposit.LoopingIn, + utxoDeposited.OutPoint: deposit.Deposited, + utxoWithdrawn.OutPoint: deposit.Withdrawn, + utxoLoopingIn.OutPoint: deposit.LoopingIn, }) server := &swapClientServer{ @@ -1096,8 +1098,8 @@ func TestListUnspentDeposits(t *testing.T) { ) require.NoError(t, err) - // Expect utxoBelow and utxoAt only. - require.Len(t, resp.Utxos, 2) + // Expect the Deposited utxo only. + require.Len(t, resp.Utxos, 1) got := map[string]struct{}{} for _, u := range resp.Utxos { got[u.Outpoint] = struct{}{} @@ -1105,25 +1107,23 @@ func TestListUnspentDeposits(t *testing.T) { // same across utxos. require.NotEmpty(t, u.StaticAddress) } - _, ok1 := got[utxoBelow.OutPoint.String()] - _, ok2 := got[utxoAt.OutPoint.String()] - require.True(t, ok1) - require.True(t, ok2) + _, ok := got[utxoDeposited.OutPoint.String()] + require.True(t, ok) }) - // Swap states, now include utxoBelow and utxoAbove1. - t.Run("Deposited on >=min included; non-Deposited excluded", + // Confirmation depth no longer changes availability; state does. + t.Run("availability ignores conf depth once deposit state is known", func(t *testing.T) { mock.SetListUnspent( []*lnwallet.Utxo{ - utxoBelow, utxoAt, utxoAbove1, - utxoAbove2, + utxoUnknown, utxoDeposited, + utxoWithdrawn, utxoLoopingIn, }) depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ - utxoAt.OutPoint: deposit.Withdrawn, - utxoAbove1.OutPoint: deposit.Deposited, - utxoAbove2.OutPoint: deposit.Withdrawn, + utxoDeposited.OutPoint: deposit.Deposited, + utxoWithdrawn.OutPoint: deposit.Withdrawn, + utxoLoopingIn.OutPoint: deposit.LoopingIn, }) server := &swapClientServer{ @@ -1136,22 +1136,20 @@ func TestListUnspentDeposits(t *testing.T) { ) require.NoError(t, err) - require.Len(t, resp.Utxos, 2) + require.Len(t, resp.Utxos, 1) got := map[string]struct{}{} for _, u := range resp.Utxos { got[u.Outpoint] = struct{}{} } - _, ok1 := got[utxoBelow.OutPoint.String()] - _, ok2 := got[utxoAbove1.OutPoint.String()] - require.True(t, ok1) - require.True(t, ok2) + _, ok := got[utxoDeposited.OutPoint.String()] + require.True(t, ok) }) - // Confirmed UTXO not present in store should be included. - t.Run("confirmed utxo not in store is included", func(t *testing.T) { + // Confirmed UTXO not present in store should be excluded. + t.Run("confirmed utxo not in store is excluded", func(t *testing.T) { // Only return a confirmed UTXO from lnd and make sure the // deposit manager/store doesn't know about it. - mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2}) + mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown}) // Empty store (no states for any outpoint). depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{}) @@ -1166,12 +1164,6 @@ func TestListUnspentDeposits(t *testing.T) { ) require.NoError(t, err) - // We expect the confirmed UTXO to be included even though it - // doesn't exist in the store yet. - require.Len(t, resp.Utxos, 1) - require.Equal( - t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint, - ) - require.NotEmpty(t, resp.Utxos[0].StaticAddress) + require.Empty(t, resp.Utxos) }) } diff --git a/loopdb/sqlc/migrations/000010_static_address_deposits.up.sql b/loopdb/sqlc/migrations/000010_static_address_deposits.up.sql index b996ea9bd..3ed5045a9 100644 --- a/loopdb/sqlc/migrations/000010_static_address_deposits.up.sql +++ b/loopdb/sqlc/migrations/000010_static_address_deposits.up.sql @@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS deposits ( amount BIGINT NOT NULL, -- confirmation_height is the absolute height at which the deposit was - -- confirmed. + -- confirmed. A value of 0 means the deposit is still unconfirmed. confirmation_height BIGINT NOT NULL, -- timeout_sweep_pk_script is the public key script that will be used to @@ -45,4 +45,4 @@ CREATE TABLE IF NOT EXISTS deposit_updates ( -- update_timestamp is the timestamp of the update. update_timestamp TIMESTAMP NOT NULL -); \ No newline at end of file +); diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 35ae71c82..b4e9bffc9 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -2962,6 +2962,144 @@ func (*FetchL402TokenResponse) Descriptor() ([]byte, []int) { return file_client_proto_rawDescGZIP(), []int{29} } +type RecoverRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional path to the encrypted backup file. If omitted, loopd restores from + // the most recent immutable L402 recovery backup in the active network data + // directory. + BackupFile string `protobuf:"bytes,1,opt,name=backup_file,json=backupFile,proto3" json:"backup_file,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecoverRequest) Reset() { + *x = RecoverRequest{} + mi := &file_client_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecoverRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecoverRequest) ProtoMessage() {} + +func (x *RecoverRequest) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecoverRequest.ProtoReflect.Descriptor instead. +func (*RecoverRequest) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{30} +} + +func (x *RecoverRequest) GetBackupFile() string { + if x != nil { + return x.BackupFile + } + return "" +} + +type RecoverResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The backup file that was restored. + BackupFile string `protobuf:"bytes,1,opt,name=backup_file,json=backupFile,proto3" json:"backup_file,omitempty"` + // Whether a paid L402 token was restored into the local token store. + RestoredL402 bool `protobuf:"varint,2,opt,name=restored_l402,json=restoredL402,proto3" json:"restored_l402,omitempty"` + // Whether static-address state was restored into loopd and lnd. + RestoredStaticAddress bool `protobuf:"varint,3,opt,name=restored_static_address,json=restoredStaticAddress,proto3" json:"restored_static_address,omitempty"` + // The restored static address, if any. + StaticAddress string `protobuf:"bytes,4,opt,name=static_address,json=staticAddress,proto3" json:"static_address,omitempty"` + // The number of deposits found during best-effort reconciliation. + NumDepositsFound uint32 `protobuf:"varint,5,opt,name=num_deposits_found,json=numDepositsFound,proto3" json:"num_deposits_found,omitempty"` + // Best-effort deposit reconciliation error text, if reconciliation failed + // after state restore completed. + DepositReconciliationError string `protobuf:"bytes,6,opt,name=deposit_reconciliation_error,json=depositReconciliationError,proto3" json:"deposit_reconciliation_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecoverResponse) Reset() { + *x = RecoverResponse{} + mi := &file_client_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecoverResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecoverResponse) ProtoMessage() {} + +func (x *RecoverResponse) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecoverResponse.ProtoReflect.Descriptor instead. +func (*RecoverResponse) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{31} +} + +func (x *RecoverResponse) GetBackupFile() string { + if x != nil { + return x.BackupFile + } + return "" +} + +func (x *RecoverResponse) GetRestoredL402() bool { + if x != nil { + return x.RestoredL402 + } + return false +} + +func (x *RecoverResponse) GetRestoredStaticAddress() bool { + if x != nil { + return x.RestoredStaticAddress + } + return false +} + +func (x *RecoverResponse) GetStaticAddress() string { + if x != nil { + return x.StaticAddress + } + return "" +} + +func (x *RecoverResponse) GetNumDepositsFound() uint32 { + if x != nil { + return x.NumDepositsFound + } + return 0 +} + +func (x *RecoverResponse) GetDepositReconciliationError() string { + if x != nil { + return x.DepositReconciliationError + } + return "" +} + type L402Token struct { state protoimpl.MessageState `protogen:"open.v1"` // The base macaroon that was baked by the auth server. @@ -2991,7 +3129,7 @@ type L402Token struct { func (x *L402Token) Reset() { *x = L402Token{} - mi := &file_client_proto_msgTypes[30] + mi := &file_client_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3003,7 +3141,7 @@ func (x *L402Token) String() string { func (*L402Token) ProtoMessage() {} func (x *L402Token) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[30] + mi := &file_client_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3016,7 +3154,7 @@ func (x *L402Token) ProtoReflect() protoreflect.Message { // Deprecated: Use L402Token.ProtoReflect.Descriptor instead. func (*L402Token) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{30} + return file_client_proto_rawDescGZIP(), []int{32} } func (x *L402Token) GetBaseMacaroon() []byte { @@ -3100,7 +3238,7 @@ type LoopStats struct { func (x *LoopStats) Reset() { *x = LoopStats{} - mi := &file_client_proto_msgTypes[31] + mi := &file_client_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3112,7 +3250,7 @@ func (x *LoopStats) String() string { func (*LoopStats) ProtoMessage() {} func (x *LoopStats) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[31] + mi := &file_client_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3125,7 +3263,7 @@ func (x *LoopStats) ProtoReflect() protoreflect.Message { // Deprecated: Use LoopStats.ProtoReflect.Descriptor instead. func (*LoopStats) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{31} + return file_client_proto_rawDescGZIP(), []int{33} } func (x *LoopStats) GetPendingCount() uint64 { @@ -3171,7 +3309,7 @@ type GetInfoRequest struct { func (x *GetInfoRequest) Reset() { *x = GetInfoRequest{} - mi := &file_client_proto_msgTypes[32] + mi := &file_client_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3183,7 +3321,7 @@ func (x *GetInfoRequest) String() string { func (*GetInfoRequest) ProtoMessage() {} func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[32] + mi := &file_client_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3196,7 +3334,7 @@ func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. func (*GetInfoRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{32} + return file_client_proto_rawDescGZIP(), []int{34} } type GetInfoResponse struct { @@ -3227,7 +3365,7 @@ type GetInfoResponse struct { func (x *GetInfoResponse) Reset() { *x = GetInfoResponse{} - mi := &file_client_proto_msgTypes[33] + mi := &file_client_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3239,7 +3377,7 @@ func (x *GetInfoResponse) String() string { func (*GetInfoResponse) ProtoMessage() {} func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[33] + mi := &file_client_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3252,7 +3390,7 @@ func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. func (*GetInfoResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{33} + return file_client_proto_rawDescGZIP(), []int{35} } func (x *GetInfoResponse) GetVersion() string { @@ -3326,7 +3464,7 @@ type GetLiquidityParamsRequest struct { func (x *GetLiquidityParamsRequest) Reset() { *x = GetLiquidityParamsRequest{} - mi := &file_client_proto_msgTypes[34] + mi := &file_client_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3338,7 +3476,7 @@ func (x *GetLiquidityParamsRequest) String() string { func (*GetLiquidityParamsRequest) ProtoMessage() {} func (x *GetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[34] + mi := &file_client_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3351,7 +3489,7 @@ func (x *GetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLiquidityParamsRequest.ProtoReflect.Descriptor instead. func (*GetLiquidityParamsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{34} + return file_client_proto_rawDescGZIP(), []int{36} } type LiquidityParameters struct { @@ -3461,7 +3599,7 @@ type LiquidityParameters struct { func (x *LiquidityParameters) Reset() { *x = LiquidityParameters{} - mi := &file_client_proto_msgTypes[35] + mi := &file_client_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3473,7 +3611,7 @@ func (x *LiquidityParameters) String() string { func (*LiquidityParameters) ProtoMessage() {} func (x *LiquidityParameters) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[35] + mi := &file_client_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3486,7 +3624,7 @@ func (x *LiquidityParameters) ProtoReflect() protoreflect.Message { // Deprecated: Use LiquidityParameters.ProtoReflect.Descriptor instead. func (*LiquidityParameters) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{35} + return file_client_proto_rawDescGZIP(), []int{37} } func (x *LiquidityParameters) GetRules() []*LiquidityRule { @@ -3696,7 +3834,7 @@ type EasyAssetAutoloopParams struct { func (x *EasyAssetAutoloopParams) Reset() { *x = EasyAssetAutoloopParams{} - mi := &file_client_proto_msgTypes[36] + mi := &file_client_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3708,7 +3846,7 @@ func (x *EasyAssetAutoloopParams) String() string { func (*EasyAssetAutoloopParams) ProtoMessage() {} func (x *EasyAssetAutoloopParams) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[36] + mi := &file_client_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3721,7 +3859,7 @@ func (x *EasyAssetAutoloopParams) ProtoReflect() protoreflect.Message { // Deprecated: Use EasyAssetAutoloopParams.ProtoReflect.Descriptor instead. func (*EasyAssetAutoloopParams) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{36} + return file_client_proto_rawDescGZIP(), []int{38} } func (x *EasyAssetAutoloopParams) GetEnabled() bool { @@ -3765,7 +3903,7 @@ type LiquidityRule struct { func (x *LiquidityRule) Reset() { *x = LiquidityRule{} - mi := &file_client_proto_msgTypes[37] + mi := &file_client_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3777,7 +3915,7 @@ func (x *LiquidityRule) String() string { func (*LiquidityRule) ProtoMessage() {} func (x *LiquidityRule) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[37] + mi := &file_client_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3790,7 +3928,7 @@ func (x *LiquidityRule) ProtoReflect() protoreflect.Message { // Deprecated: Use LiquidityRule.ProtoReflect.Descriptor instead. func (*LiquidityRule) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{37} + return file_client_proto_rawDescGZIP(), []int{39} } func (x *LiquidityRule) GetChannelId() uint64 { @@ -3848,7 +3986,7 @@ type SetLiquidityParamsRequest struct { func (x *SetLiquidityParamsRequest) Reset() { *x = SetLiquidityParamsRequest{} - mi := &file_client_proto_msgTypes[38] + mi := &file_client_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3860,7 +3998,7 @@ func (x *SetLiquidityParamsRequest) String() string { func (*SetLiquidityParamsRequest) ProtoMessage() {} func (x *SetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[38] + mi := &file_client_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3873,7 +4011,7 @@ func (x *SetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLiquidityParamsRequest.ProtoReflect.Descriptor instead. func (*SetLiquidityParamsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{38} + return file_client_proto_rawDescGZIP(), []int{40} } func (x *SetLiquidityParamsRequest) GetParameters() *LiquidityParameters { @@ -3891,7 +4029,7 @@ type SetLiquidityParamsResponse struct { func (x *SetLiquidityParamsResponse) Reset() { *x = SetLiquidityParamsResponse{} - mi := &file_client_proto_msgTypes[39] + mi := &file_client_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3903,7 +4041,7 @@ func (x *SetLiquidityParamsResponse) String() string { func (*SetLiquidityParamsResponse) ProtoMessage() {} func (x *SetLiquidityParamsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[39] + mi := &file_client_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3916,7 +4054,7 @@ func (x *SetLiquidityParamsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLiquidityParamsResponse.ProtoReflect.Descriptor instead. func (*SetLiquidityParamsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{39} + return file_client_proto_rawDescGZIP(), []int{41} } type SuggestSwapsRequest struct { @@ -3927,7 +4065,7 @@ type SuggestSwapsRequest struct { func (x *SuggestSwapsRequest) Reset() { *x = SuggestSwapsRequest{} - mi := &file_client_proto_msgTypes[40] + mi := &file_client_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3939,7 +4077,7 @@ func (x *SuggestSwapsRequest) String() string { func (*SuggestSwapsRequest) ProtoMessage() {} func (x *SuggestSwapsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[40] + mi := &file_client_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3952,7 +4090,7 @@ func (x *SuggestSwapsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SuggestSwapsRequest.ProtoReflect.Descriptor instead. func (*SuggestSwapsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{40} + return file_client_proto_rawDescGZIP(), []int{42} } type Disqualified struct { @@ -3969,7 +4107,7 @@ type Disqualified struct { func (x *Disqualified) Reset() { *x = Disqualified{} - mi := &file_client_proto_msgTypes[41] + mi := &file_client_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3981,7 +4119,7 @@ func (x *Disqualified) String() string { func (*Disqualified) ProtoMessage() {} func (x *Disqualified) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[41] + mi := &file_client_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3994,7 +4132,7 @@ func (x *Disqualified) ProtoReflect() protoreflect.Message { // Deprecated: Use Disqualified.ProtoReflect.Descriptor instead. func (*Disqualified) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{41} + return file_client_proto_rawDescGZIP(), []int{43} } func (x *Disqualified) GetChannelId() uint64 { @@ -4033,7 +4171,7 @@ type SuggestSwapsResponse struct { func (x *SuggestSwapsResponse) Reset() { *x = SuggestSwapsResponse{} - mi := &file_client_proto_msgTypes[42] + mi := &file_client_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4045,7 +4183,7 @@ func (x *SuggestSwapsResponse) String() string { func (*SuggestSwapsResponse) ProtoMessage() {} func (x *SuggestSwapsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[42] + mi := &file_client_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4058,7 +4196,7 @@ func (x *SuggestSwapsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SuggestSwapsResponse.ProtoReflect.Descriptor instead. func (*SuggestSwapsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{42} + return file_client_proto_rawDescGZIP(), []int{44} } func (x *SuggestSwapsResponse) GetLoopOut() []*LoopOutRequest { @@ -4097,7 +4235,7 @@ type AbandonSwapRequest struct { func (x *AbandonSwapRequest) Reset() { *x = AbandonSwapRequest{} - mi := &file_client_proto_msgTypes[43] + mi := &file_client_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4109,7 +4247,7 @@ func (x *AbandonSwapRequest) String() string { func (*AbandonSwapRequest) ProtoMessage() {} func (x *AbandonSwapRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[43] + mi := &file_client_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4122,7 +4260,7 @@ func (x *AbandonSwapRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AbandonSwapRequest.ProtoReflect.Descriptor instead. func (*AbandonSwapRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{43} + return file_client_proto_rawDescGZIP(), []int{45} } func (x *AbandonSwapRequest) GetId() []byte { @@ -4147,7 +4285,7 @@ type AbandonSwapResponse struct { func (x *AbandonSwapResponse) Reset() { *x = AbandonSwapResponse{} - mi := &file_client_proto_msgTypes[44] + mi := &file_client_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4159,7 +4297,7 @@ func (x *AbandonSwapResponse) String() string { func (*AbandonSwapResponse) ProtoMessage() {} func (x *AbandonSwapResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[44] + mi := &file_client_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4172,7 +4310,7 @@ func (x *AbandonSwapResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AbandonSwapResponse.ProtoReflect.Descriptor instead. func (*AbandonSwapResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{44} + return file_client_proto_rawDescGZIP(), []int{46} } type ListReservationsRequest struct { @@ -4183,7 +4321,7 @@ type ListReservationsRequest struct { func (x *ListReservationsRequest) Reset() { *x = ListReservationsRequest{} - mi := &file_client_proto_msgTypes[45] + mi := &file_client_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4195,7 +4333,7 @@ func (x *ListReservationsRequest) String() string { func (*ListReservationsRequest) ProtoMessage() {} func (x *ListReservationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[45] + mi := &file_client_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4208,7 +4346,7 @@ func (x *ListReservationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListReservationsRequest.ProtoReflect.Descriptor instead. func (*ListReservationsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{45} + return file_client_proto_rawDescGZIP(), []int{47} } type ListReservationsResponse struct { @@ -4221,7 +4359,7 @@ type ListReservationsResponse struct { func (x *ListReservationsResponse) Reset() { *x = ListReservationsResponse{} - mi := &file_client_proto_msgTypes[46] + mi := &file_client_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4233,7 +4371,7 @@ func (x *ListReservationsResponse) String() string { func (*ListReservationsResponse) ProtoMessage() {} func (x *ListReservationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[46] + mi := &file_client_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4246,7 +4384,7 @@ func (x *ListReservationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListReservationsResponse.ProtoReflect.Descriptor instead. func (*ListReservationsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{46} + return file_client_proto_rawDescGZIP(), []int{48} } func (x *ListReservationsResponse) GetReservations() []*ClientReservation { @@ -4276,7 +4414,7 @@ type ClientReservation struct { func (x *ClientReservation) Reset() { *x = ClientReservation{} - mi := &file_client_proto_msgTypes[47] + mi := &file_client_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4288,7 +4426,7 @@ func (x *ClientReservation) String() string { func (*ClientReservation) ProtoMessage() {} func (x *ClientReservation) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[47] + mi := &file_client_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4301,7 +4439,7 @@ func (x *ClientReservation) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientReservation.ProtoReflect.Descriptor instead. func (*ClientReservation) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{47} + return file_client_proto_rawDescGZIP(), []int{49} } func (x *ClientReservation) GetReservationId() []byte { @@ -4363,7 +4501,7 @@ type InstantOutRequest struct { func (x *InstantOutRequest) Reset() { *x = InstantOutRequest{} - mi := &file_client_proto_msgTypes[48] + mi := &file_client_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4375,7 +4513,7 @@ func (x *InstantOutRequest) String() string { func (*InstantOutRequest) ProtoMessage() {} func (x *InstantOutRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[48] + mi := &file_client_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4388,7 +4526,7 @@ func (x *InstantOutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutRequest.ProtoReflect.Descriptor instead. func (*InstantOutRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{48} + return file_client_proto_rawDescGZIP(), []int{50} } func (x *InstantOutRequest) GetReservationIds() [][]byte { @@ -4426,7 +4564,7 @@ type InstantOutResponse struct { func (x *InstantOutResponse) Reset() { *x = InstantOutResponse{} - mi := &file_client_proto_msgTypes[49] + mi := &file_client_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4438,7 +4576,7 @@ func (x *InstantOutResponse) String() string { func (*InstantOutResponse) ProtoMessage() {} func (x *InstantOutResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[49] + mi := &file_client_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4451,7 +4589,7 @@ func (x *InstantOutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutResponse.ProtoReflect.Descriptor instead. func (*InstantOutResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{49} + return file_client_proto_rawDescGZIP(), []int{51} } func (x *InstantOutResponse) GetInstantOutHash() []byte { @@ -4492,7 +4630,7 @@ type InstantOutQuoteRequest struct { func (x *InstantOutQuoteRequest) Reset() { *x = InstantOutQuoteRequest{} - mi := &file_client_proto_msgTypes[50] + mi := &file_client_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4504,7 +4642,7 @@ func (x *InstantOutQuoteRequest) String() string { func (*InstantOutQuoteRequest) ProtoMessage() {} func (x *InstantOutQuoteRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[50] + mi := &file_client_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4517,7 +4655,7 @@ func (x *InstantOutQuoteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutQuoteRequest.ProtoReflect.Descriptor instead. func (*InstantOutQuoteRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{50} + return file_client_proto_rawDescGZIP(), []int{52} } func (x *InstantOutQuoteRequest) GetAmt() uint64 { @@ -4555,7 +4693,7 @@ type InstantOutQuoteResponse struct { func (x *InstantOutQuoteResponse) Reset() { *x = InstantOutQuoteResponse{} - mi := &file_client_proto_msgTypes[51] + mi := &file_client_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4567,7 +4705,7 @@ func (x *InstantOutQuoteResponse) String() string { func (*InstantOutQuoteResponse) ProtoMessage() {} func (x *InstantOutQuoteResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[51] + mi := &file_client_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4580,7 +4718,7 @@ func (x *InstantOutQuoteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutQuoteResponse.ProtoReflect.Descriptor instead. func (*InstantOutQuoteResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{51} + return file_client_proto_rawDescGZIP(), []int{53} } func (x *InstantOutQuoteResponse) GetServiceFeeSat() int64 { @@ -4605,7 +4743,7 @@ type ListInstantOutsRequest struct { func (x *ListInstantOutsRequest) Reset() { *x = ListInstantOutsRequest{} - mi := &file_client_proto_msgTypes[52] + mi := &file_client_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4617,7 +4755,7 @@ func (x *ListInstantOutsRequest) String() string { func (*ListInstantOutsRequest) ProtoMessage() {} func (x *ListInstantOutsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[52] + mi := &file_client_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4630,7 +4768,7 @@ func (x *ListInstantOutsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListInstantOutsRequest.ProtoReflect.Descriptor instead. func (*ListInstantOutsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{52} + return file_client_proto_rawDescGZIP(), []int{54} } type ListInstantOutsResponse struct { @@ -4643,7 +4781,7 @@ type ListInstantOutsResponse struct { func (x *ListInstantOutsResponse) Reset() { *x = ListInstantOutsResponse{} - mi := &file_client_proto_msgTypes[53] + mi := &file_client_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4655,7 +4793,7 @@ func (x *ListInstantOutsResponse) String() string { func (*ListInstantOutsResponse) ProtoMessage() {} func (x *ListInstantOutsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[53] + mi := &file_client_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4668,7 +4806,7 @@ func (x *ListInstantOutsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListInstantOutsResponse.ProtoReflect.Descriptor instead. func (*ListInstantOutsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{53} + return file_client_proto_rawDescGZIP(), []int{55} } func (x *ListInstantOutsResponse) GetSwaps() []*InstantOut { @@ -4696,7 +4834,7 @@ type InstantOut struct { func (x *InstantOut) Reset() { *x = InstantOut{} - mi := &file_client_proto_msgTypes[54] + mi := &file_client_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4708,7 +4846,7 @@ func (x *InstantOut) String() string { func (*InstantOut) ProtoMessage() {} func (x *InstantOut) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[54] + mi := &file_client_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4721,7 +4859,7 @@ func (x *InstantOut) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOut.ProtoReflect.Descriptor instead. func (*InstantOut) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{54} + return file_client_proto_rawDescGZIP(), []int{56} } func (x *InstantOut) GetSwapHash() []byte { @@ -4769,7 +4907,7 @@ type NewStaticAddressRequest struct { func (x *NewStaticAddressRequest) Reset() { *x = NewStaticAddressRequest{} - mi := &file_client_proto_msgTypes[55] + mi := &file_client_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4781,7 +4919,7 @@ func (x *NewStaticAddressRequest) String() string { func (*NewStaticAddressRequest) ProtoMessage() {} func (x *NewStaticAddressRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[55] + mi := &file_client_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4794,7 +4932,7 @@ func (x *NewStaticAddressRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NewStaticAddressRequest.ProtoReflect.Descriptor instead. func (*NewStaticAddressRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{55} + return file_client_proto_rawDescGZIP(), []int{57} } func (x *NewStaticAddressRequest) GetClientKey() []byte { @@ -4816,7 +4954,7 @@ type NewStaticAddressResponse struct { func (x *NewStaticAddressResponse) Reset() { *x = NewStaticAddressResponse{} - mi := &file_client_proto_msgTypes[56] + mi := &file_client_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4828,7 +4966,7 @@ func (x *NewStaticAddressResponse) String() string { func (*NewStaticAddressResponse) ProtoMessage() {} func (x *NewStaticAddressResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[56] + mi := &file_client_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4841,7 +4979,7 @@ func (x *NewStaticAddressResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NewStaticAddressResponse.ProtoReflect.Descriptor instead. func (*NewStaticAddressResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{56} + return file_client_proto_rawDescGZIP(), []int{58} } func (x *NewStaticAddressResponse) GetAddress() string { @@ -4871,7 +5009,7 @@ type ListUnspentDepositsRequest struct { func (x *ListUnspentDepositsRequest) Reset() { *x = ListUnspentDepositsRequest{} - mi := &file_client_proto_msgTypes[57] + mi := &file_client_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4883,7 +5021,7 @@ func (x *ListUnspentDepositsRequest) String() string { func (*ListUnspentDepositsRequest) ProtoMessage() {} func (x *ListUnspentDepositsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[57] + mi := &file_client_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4896,7 +5034,7 @@ func (x *ListUnspentDepositsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUnspentDepositsRequest.ProtoReflect.Descriptor instead. func (*ListUnspentDepositsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{57} + return file_client_proto_rawDescGZIP(), []int{59} } func (x *ListUnspentDepositsRequest) GetMinConfs() int32 { @@ -4923,7 +5061,7 @@ type ListUnspentDepositsResponse struct { func (x *ListUnspentDepositsResponse) Reset() { *x = ListUnspentDepositsResponse{} - mi := &file_client_proto_msgTypes[58] + mi := &file_client_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4935,7 +5073,7 @@ func (x *ListUnspentDepositsResponse) String() string { func (*ListUnspentDepositsResponse) ProtoMessage() {} func (x *ListUnspentDepositsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[58] + mi := &file_client_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4948,7 +5086,7 @@ func (x *ListUnspentDepositsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUnspentDepositsResponse.ProtoReflect.Descriptor instead. func (*ListUnspentDepositsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{58} + return file_client_proto_rawDescGZIP(), []int{60} } func (x *ListUnspentDepositsResponse) GetUtxos() []*Utxo { @@ -4974,7 +5112,7 @@ type Utxo struct { func (x *Utxo) Reset() { *x = Utxo{} - mi := &file_client_proto_msgTypes[59] + mi := &file_client_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4986,7 +5124,7 @@ func (x *Utxo) String() string { func (*Utxo) ProtoMessage() {} func (x *Utxo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[59] + mi := &file_client_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4999,7 +5137,7 @@ func (x *Utxo) ProtoReflect() protoreflect.Message { // Deprecated: Use Utxo.ProtoReflect.Descriptor instead. func (*Utxo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{59} + return file_client_proto_rawDescGZIP(), []int{61} } func (x *Utxo) GetStaticAddress() string { @@ -5052,7 +5190,7 @@ type WithdrawDepositsRequest struct { func (x *WithdrawDepositsRequest) Reset() { *x = WithdrawDepositsRequest{} - mi := &file_client_proto_msgTypes[60] + mi := &file_client_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5064,7 +5202,7 @@ func (x *WithdrawDepositsRequest) String() string { func (*WithdrawDepositsRequest) ProtoMessage() {} func (x *WithdrawDepositsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[60] + mi := &file_client_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5077,7 +5215,7 @@ func (x *WithdrawDepositsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WithdrawDepositsRequest.ProtoReflect.Descriptor instead. func (*WithdrawDepositsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{60} + return file_client_proto_rawDescGZIP(), []int{62} } func (x *WithdrawDepositsRequest) GetOutpoints() []*lnrpc.OutPoint { @@ -5127,7 +5265,7 @@ type WithdrawDepositsResponse struct { func (x *WithdrawDepositsResponse) Reset() { *x = WithdrawDepositsResponse{} - mi := &file_client_proto_msgTypes[61] + mi := &file_client_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5139,7 +5277,7 @@ func (x *WithdrawDepositsResponse) String() string { func (*WithdrawDepositsResponse) ProtoMessage() {} func (x *WithdrawDepositsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[61] + mi := &file_client_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5152,7 +5290,7 @@ func (x *WithdrawDepositsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WithdrawDepositsResponse.ProtoReflect.Descriptor instead. func (*WithdrawDepositsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{61} + return file_client_proto_rawDescGZIP(), []int{63} } func (x *WithdrawDepositsResponse) GetWithdrawalTxHash() string { @@ -5181,7 +5319,7 @@ type ListStaticAddressDepositsRequest struct { func (x *ListStaticAddressDepositsRequest) Reset() { *x = ListStaticAddressDepositsRequest{} - mi := &file_client_proto_msgTypes[62] + mi := &file_client_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5193,7 +5331,7 @@ func (x *ListStaticAddressDepositsRequest) String() string { func (*ListStaticAddressDepositsRequest) ProtoMessage() {} func (x *ListStaticAddressDepositsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[62] + mi := &file_client_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5206,7 +5344,7 @@ func (x *ListStaticAddressDepositsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStaticAddressDepositsRequest.ProtoReflect.Descriptor instead. func (*ListStaticAddressDepositsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{62} + return file_client_proto_rawDescGZIP(), []int{64} } func (x *ListStaticAddressDepositsRequest) GetStateFilter() DepositState { @@ -5233,7 +5371,7 @@ type ListStaticAddressDepositsResponse struct { func (x *ListStaticAddressDepositsResponse) Reset() { *x = ListStaticAddressDepositsResponse{} - mi := &file_client_proto_msgTypes[63] + mi := &file_client_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5245,7 +5383,7 @@ func (x *ListStaticAddressDepositsResponse) String() string { func (*ListStaticAddressDepositsResponse) ProtoMessage() {} func (x *ListStaticAddressDepositsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[63] + mi := &file_client_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5258,7 +5396,7 @@ func (x *ListStaticAddressDepositsResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ListStaticAddressDepositsResponse.ProtoReflect.Descriptor instead. func (*ListStaticAddressDepositsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{63} + return file_client_proto_rawDescGZIP(), []int{65} } func (x *ListStaticAddressDepositsResponse) GetFilteredDeposits() []*Deposit { @@ -5276,7 +5414,7 @@ type ListStaticAddressWithdrawalRequest struct { func (x *ListStaticAddressWithdrawalRequest) Reset() { *x = ListStaticAddressWithdrawalRequest{} - mi := &file_client_proto_msgTypes[64] + mi := &file_client_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5288,7 +5426,7 @@ func (x *ListStaticAddressWithdrawalRequest) String() string { func (*ListStaticAddressWithdrawalRequest) ProtoMessage() {} func (x *ListStaticAddressWithdrawalRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[64] + mi := &file_client_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5301,7 +5439,7 @@ func (x *ListStaticAddressWithdrawalRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ListStaticAddressWithdrawalRequest.ProtoReflect.Descriptor instead. func (*ListStaticAddressWithdrawalRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{64} + return file_client_proto_rawDescGZIP(), []int{66} } type ListStaticAddressWithdrawalResponse struct { @@ -5314,7 +5452,7 @@ type ListStaticAddressWithdrawalResponse struct { func (x *ListStaticAddressWithdrawalResponse) Reset() { *x = ListStaticAddressWithdrawalResponse{} - mi := &file_client_proto_msgTypes[65] + mi := &file_client_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5326,7 +5464,7 @@ func (x *ListStaticAddressWithdrawalResponse) String() string { func (*ListStaticAddressWithdrawalResponse) ProtoMessage() {} func (x *ListStaticAddressWithdrawalResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[65] + mi := &file_client_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5339,7 +5477,7 @@ func (x *ListStaticAddressWithdrawalResponse) ProtoReflect() protoreflect.Messag // Deprecated: Use ListStaticAddressWithdrawalResponse.ProtoReflect.Descriptor instead. func (*ListStaticAddressWithdrawalResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{65} + return file_client_proto_rawDescGZIP(), []int{67} } func (x *ListStaticAddressWithdrawalResponse) GetWithdrawals() []*StaticAddressWithdrawal { @@ -5357,7 +5495,7 @@ type ListStaticAddressSwapsRequest struct { func (x *ListStaticAddressSwapsRequest) Reset() { *x = ListStaticAddressSwapsRequest{} - mi := &file_client_proto_msgTypes[66] + mi := &file_client_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5369,7 +5507,7 @@ func (x *ListStaticAddressSwapsRequest) String() string { func (*ListStaticAddressSwapsRequest) ProtoMessage() {} func (x *ListStaticAddressSwapsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[66] + mi := &file_client_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5382,7 +5520,7 @@ func (x *ListStaticAddressSwapsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStaticAddressSwapsRequest.ProtoReflect.Descriptor instead. func (*ListStaticAddressSwapsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{66} + return file_client_proto_rawDescGZIP(), []int{68} } type ListStaticAddressSwapsResponse struct { @@ -5395,7 +5533,7 @@ type ListStaticAddressSwapsResponse struct { func (x *ListStaticAddressSwapsResponse) Reset() { *x = ListStaticAddressSwapsResponse{} - mi := &file_client_proto_msgTypes[67] + mi := &file_client_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5407,7 +5545,7 @@ func (x *ListStaticAddressSwapsResponse) String() string { func (*ListStaticAddressSwapsResponse) ProtoMessage() {} func (x *ListStaticAddressSwapsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[67] + mi := &file_client_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5420,7 +5558,7 @@ func (x *ListStaticAddressSwapsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStaticAddressSwapsResponse.ProtoReflect.Descriptor instead. func (*ListStaticAddressSwapsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{67} + return file_client_proto_rawDescGZIP(), []int{69} } func (x *ListStaticAddressSwapsResponse) GetSwaps() []*StaticAddressLoopInSwap { @@ -5438,7 +5576,7 @@ type StaticAddressSummaryRequest struct { func (x *StaticAddressSummaryRequest) Reset() { *x = StaticAddressSummaryRequest{} - mi := &file_client_proto_msgTypes[68] + mi := &file_client_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5450,7 +5588,7 @@ func (x *StaticAddressSummaryRequest) String() string { func (*StaticAddressSummaryRequest) ProtoMessage() {} func (x *StaticAddressSummaryRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[68] + mi := &file_client_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5463,7 +5601,7 @@ func (x *StaticAddressSummaryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressSummaryRequest.ProtoReflect.Descriptor instead. func (*StaticAddressSummaryRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{68} + return file_client_proto_rawDescGZIP(), []int{70} } type StaticAddressSummaryResponse struct { @@ -5494,7 +5632,7 @@ type StaticAddressSummaryResponse struct { func (x *StaticAddressSummaryResponse) Reset() { *x = StaticAddressSummaryResponse{} - mi := &file_client_proto_msgTypes[69] + mi := &file_client_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5506,7 +5644,7 @@ func (x *StaticAddressSummaryResponse) String() string { func (*StaticAddressSummaryResponse) ProtoMessage() {} func (x *StaticAddressSummaryResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[69] + mi := &file_client_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5519,7 +5657,7 @@ func (x *StaticAddressSummaryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressSummaryResponse.ProtoReflect.Descriptor instead. func (*StaticAddressSummaryResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{69} + return file_client_proto_rawDescGZIP(), []int{71} } func (x *StaticAddressSummaryResponse) GetStaticAddress() string { @@ -5616,7 +5754,7 @@ type Deposit struct { func (x *Deposit) Reset() { *x = Deposit{} - mi := &file_client_proto_msgTypes[70] + mi := &file_client_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5628,7 +5766,7 @@ func (x *Deposit) String() string { func (*Deposit) ProtoMessage() {} func (x *Deposit) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[70] + mi := &file_client_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5641,7 +5779,7 @@ func (x *Deposit) ProtoReflect() protoreflect.Message { // Deprecated: Use Deposit.ProtoReflect.Descriptor instead. func (*Deposit) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{70} + return file_client_proto_rawDescGZIP(), []int{72} } func (x *Deposit) GetId() []byte { @@ -5715,7 +5853,7 @@ type StaticAddressWithdrawal struct { func (x *StaticAddressWithdrawal) Reset() { *x = StaticAddressWithdrawal{} - mi := &file_client_proto_msgTypes[71] + mi := &file_client_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5727,7 +5865,7 @@ func (x *StaticAddressWithdrawal) String() string { func (*StaticAddressWithdrawal) ProtoMessage() {} func (x *StaticAddressWithdrawal) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[71] + mi := &file_client_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5740,7 +5878,7 @@ func (x *StaticAddressWithdrawal) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressWithdrawal.ProtoReflect.Descriptor instead. func (*StaticAddressWithdrawal) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{71} + return file_client_proto_rawDescGZIP(), []int{73} } func (x *StaticAddressWithdrawal) GetTxId() string { @@ -5805,7 +5943,7 @@ type StaticAddressLoopInSwap struct { func (x *StaticAddressLoopInSwap) Reset() { *x = StaticAddressLoopInSwap{} - mi := &file_client_proto_msgTypes[72] + mi := &file_client_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5817,7 +5955,7 @@ func (x *StaticAddressLoopInSwap) String() string { func (*StaticAddressLoopInSwap) ProtoMessage() {} func (x *StaticAddressLoopInSwap) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[72] + mi := &file_client_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5830,7 +5968,7 @@ func (x *StaticAddressLoopInSwap) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressLoopInSwap.ProtoReflect.Descriptor instead. func (*StaticAddressLoopInSwap) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{72} + return file_client_proto_rawDescGZIP(), []int{74} } func (x *StaticAddressLoopInSwap) GetSwapHash() []byte { @@ -5928,7 +6066,7 @@ type StaticAddressLoopInRequest struct { func (x *StaticAddressLoopInRequest) Reset() { *x = StaticAddressLoopInRequest{} - mi := &file_client_proto_msgTypes[73] + mi := &file_client_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5940,7 +6078,7 @@ func (x *StaticAddressLoopInRequest) String() string { func (*StaticAddressLoopInRequest) ProtoMessage() {} func (x *StaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[73] + mi := &file_client_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5953,7 +6091,7 @@ func (x *StaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressLoopInRequest.ProtoReflect.Descriptor instead. func (*StaticAddressLoopInRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{73} + return file_client_proto_rawDescGZIP(), []int{75} } func (x *StaticAddressLoopInRequest) GetOutpoints() []string { @@ -6073,7 +6211,7 @@ type StaticAddressLoopInResponse struct { func (x *StaticAddressLoopInResponse) Reset() { *x = StaticAddressLoopInResponse{} - mi := &file_client_proto_msgTypes[74] + mi := &file_client_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6085,7 +6223,7 @@ func (x *StaticAddressLoopInResponse) String() string { func (*StaticAddressLoopInResponse) ProtoMessage() {} func (x *StaticAddressLoopInResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[74] + mi := &file_client_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6098,7 +6236,7 @@ func (x *StaticAddressLoopInResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressLoopInResponse.ProtoReflect.Descriptor instead. func (*StaticAddressLoopInResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{74} + return file_client_proto_rawDescGZIP(), []int{76} } func (x *StaticAddressLoopInResponse) GetSwapHash() []byte { @@ -6227,7 +6365,7 @@ type AssetLoopOutRequest struct { func (x *AssetLoopOutRequest) Reset() { *x = AssetLoopOutRequest{} - mi := &file_client_proto_msgTypes[75] + mi := &file_client_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6239,7 +6377,7 @@ func (x *AssetLoopOutRequest) String() string { func (*AssetLoopOutRequest) ProtoMessage() {} func (x *AssetLoopOutRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[75] + mi := &file_client_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6252,7 +6390,7 @@ func (x *AssetLoopOutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetLoopOutRequest.ProtoReflect.Descriptor instead. func (*AssetLoopOutRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{75} + return file_client_proto_rawDescGZIP(), []int{77} } func (x *AssetLoopOutRequest) GetAssetId() []byte { @@ -6307,7 +6445,7 @@ type AssetRfqInfo struct { func (x *AssetRfqInfo) Reset() { *x = AssetRfqInfo{} - mi := &file_client_proto_msgTypes[76] + mi := &file_client_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6319,7 +6457,7 @@ func (x *AssetRfqInfo) String() string { func (*AssetRfqInfo) ProtoMessage() {} func (x *AssetRfqInfo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[76] + mi := &file_client_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6332,7 +6470,7 @@ func (x *AssetRfqInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetRfqInfo.ProtoReflect.Descriptor instead. func (*AssetRfqInfo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{76} + return file_client_proto_rawDescGZIP(), []int{78} } func (x *AssetRfqInfo) GetPrepayRfqId() []byte { @@ -6421,7 +6559,7 @@ type FixedPoint struct { func (x *FixedPoint) Reset() { *x = FixedPoint{} - mi := &file_client_proto_msgTypes[77] + mi := &file_client_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6433,7 +6571,7 @@ func (x *FixedPoint) String() string { func (*FixedPoint) ProtoMessage() {} func (x *FixedPoint) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[77] + mi := &file_client_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6446,7 +6584,7 @@ func (x *FixedPoint) ProtoReflect() protoreflect.Message { // Deprecated: Use FixedPoint.ProtoReflect.Descriptor instead. func (*FixedPoint) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{77} + return file_client_proto_rawDescGZIP(), []int{79} } func (x *FixedPoint) GetCoefficient() string { @@ -6477,7 +6615,7 @@ type AssetLoopOutInfo struct { func (x *AssetLoopOutInfo) Reset() { *x = AssetLoopOutInfo{} - mi := &file_client_proto_msgTypes[78] + mi := &file_client_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6489,7 +6627,7 @@ func (x *AssetLoopOutInfo) String() string { func (*AssetLoopOutInfo) ProtoMessage() {} func (x *AssetLoopOutInfo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[78] + mi := &file_client_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6502,7 +6640,7 @@ func (x *AssetLoopOutInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetLoopOutInfo.ProtoReflect.Descriptor instead. func (*AssetLoopOutInfo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{78} + return file_client_proto_rawDescGZIP(), []int{80} } func (x *AssetLoopOutInfo) GetAssetId() string { @@ -6700,7 +6838,18 @@ const file_client_proto_rawDesc = "" + "\x0eTokensResponse\x12*\n" + "\x06tokens\x18\x01 \x03(\v2\x12.looprpc.L402TokenR\x06tokens\"\x17\n" + "\x15FetchL402TokenRequest\"\x18\n" + - "\x16FetchL402TokenResponse\"\xcb\x02\n" + + "\x16FetchL402TokenResponse\"1\n" + + "\x0eRecoverRequest\x12\x1f\n" + + "\vbackup_file\x18\x01 \x01(\tR\n" + + "backupFile\"\xa6\x02\n" + + "\x0fRecoverResponse\x12\x1f\n" + + "\vbackup_file\x18\x01 \x01(\tR\n" + + "backupFile\x12#\n" + + "\rrestored_l402\x18\x02 \x01(\bR\frestoredL402\x126\n" + + "\x17restored_static_address\x18\x03 \x01(\bR\x15restoredStaticAddress\x12%\n" + + "\x0estatic_address\x18\x04 \x01(\tR\rstaticAddress\x12,\n" + + "\x12num_deposits_found\x18\x05 \x01(\rR\x10numDepositsFound\x12@\n" + + "\x1cdeposit_reconciliation_error\x18\x06 \x01(\tR\x1adepositReconciliationError\"\xcb\x02\n" + "\tL402Token\x12#\n" + "\rbase_macaroon\x18\x01 \x01(\fR\fbaseMacaroon\x12!\n" + "\fpayment_hash\x18\x02 \x01(\fR\vpaymentHash\x12)\n" + @@ -7030,7 +7179,7 @@ const file_client_proto_rawDesc = "" + "\x1eSUCCEEDED_TRANSITIONING_FAILED\x10\t\x12\x13\n" + "\x0fUNLOCK_DEPOSITS\x10\n" + "\x12\x1e\n" + - "\x1aFAILED_STATIC_ADDRESS_SWAP\x10\v2\xca\x14\n" + + "\x1aFAILED_STATIC_ADDRESS_SWAP\x10\v2\x88\x15\n" + "\n" + "SwapClient\x129\n" + "\aLoopOut\x12\x17.looprpc.LoopOutRequest\x1a\x15.looprpc.SwapResponse\x127\n" + @@ -7048,6 +7197,7 @@ const file_client_proto_rawDesc = "" + "\rGetL402Tokens\x12\x16.looprpc.TokensRequest\x1a\x17.looprpc.TokensResponse\x12@\n" + "\rGetLsatTokens\x12\x16.looprpc.TokensRequest\x1a\x17.looprpc.TokensResponse\x12Q\n" + "\x0eFetchL402Token\x12\x1e.looprpc.FetchL402TokenRequest\x1a\x1f.looprpc.FetchL402TokenResponse\x12<\n" + + "\aRecover\x12\x17.looprpc.RecoverRequest\x1a\x18.looprpc.RecoverResponse\x12<\n" + "\aGetInfo\x12\x17.looprpc.GetInfoRequest\x1a\x18.looprpc.GetInfoResponse\x12E\n" + "\n" + "StopDaemon\x12\x1a.looprpc.StopDaemonRequest\x1a\x1b.looprpc.StopDaemonResponse\x12V\n" + @@ -7082,7 +7232,7 @@ func file_client_proto_rawDescGZIP() []byte { } var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 9) -var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 80) +var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 82) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType @@ -7123,117 +7273,119 @@ var file_client_proto_goTypes = []any{ (*TokensResponse)(nil), // 36: looprpc.TokensResponse (*FetchL402TokenRequest)(nil), // 37: looprpc.FetchL402TokenRequest (*FetchL402TokenResponse)(nil), // 38: looprpc.FetchL402TokenResponse - (*L402Token)(nil), // 39: looprpc.L402Token - (*LoopStats)(nil), // 40: looprpc.LoopStats - (*GetInfoRequest)(nil), // 41: looprpc.GetInfoRequest - (*GetInfoResponse)(nil), // 42: looprpc.GetInfoResponse - (*GetLiquidityParamsRequest)(nil), // 43: looprpc.GetLiquidityParamsRequest - (*LiquidityParameters)(nil), // 44: looprpc.LiquidityParameters - (*EasyAssetAutoloopParams)(nil), // 45: looprpc.EasyAssetAutoloopParams - (*LiquidityRule)(nil), // 46: looprpc.LiquidityRule - (*SetLiquidityParamsRequest)(nil), // 47: looprpc.SetLiquidityParamsRequest - (*SetLiquidityParamsResponse)(nil), // 48: looprpc.SetLiquidityParamsResponse - (*SuggestSwapsRequest)(nil), // 49: looprpc.SuggestSwapsRequest - (*Disqualified)(nil), // 50: looprpc.Disqualified - (*SuggestSwapsResponse)(nil), // 51: looprpc.SuggestSwapsResponse - (*AbandonSwapRequest)(nil), // 52: looprpc.AbandonSwapRequest - (*AbandonSwapResponse)(nil), // 53: looprpc.AbandonSwapResponse - (*ListReservationsRequest)(nil), // 54: looprpc.ListReservationsRequest - (*ListReservationsResponse)(nil), // 55: looprpc.ListReservationsResponse - (*ClientReservation)(nil), // 56: looprpc.ClientReservation - (*InstantOutRequest)(nil), // 57: looprpc.InstantOutRequest - (*InstantOutResponse)(nil), // 58: looprpc.InstantOutResponse - (*InstantOutQuoteRequest)(nil), // 59: looprpc.InstantOutQuoteRequest - (*InstantOutQuoteResponse)(nil), // 60: looprpc.InstantOutQuoteResponse - (*ListInstantOutsRequest)(nil), // 61: looprpc.ListInstantOutsRequest - (*ListInstantOutsResponse)(nil), // 62: looprpc.ListInstantOutsResponse - (*InstantOut)(nil), // 63: looprpc.InstantOut - (*NewStaticAddressRequest)(nil), // 64: looprpc.NewStaticAddressRequest - (*NewStaticAddressResponse)(nil), // 65: looprpc.NewStaticAddressResponse - (*ListUnspentDepositsRequest)(nil), // 66: looprpc.ListUnspentDepositsRequest - (*ListUnspentDepositsResponse)(nil), // 67: looprpc.ListUnspentDepositsResponse - (*Utxo)(nil), // 68: looprpc.Utxo - (*WithdrawDepositsRequest)(nil), // 69: looprpc.WithdrawDepositsRequest - (*WithdrawDepositsResponse)(nil), // 70: looprpc.WithdrawDepositsResponse - (*ListStaticAddressDepositsRequest)(nil), // 71: looprpc.ListStaticAddressDepositsRequest - (*ListStaticAddressDepositsResponse)(nil), // 72: looprpc.ListStaticAddressDepositsResponse - (*ListStaticAddressWithdrawalRequest)(nil), // 73: looprpc.ListStaticAddressWithdrawalRequest - (*ListStaticAddressWithdrawalResponse)(nil), // 74: looprpc.ListStaticAddressWithdrawalResponse - (*ListStaticAddressSwapsRequest)(nil), // 75: looprpc.ListStaticAddressSwapsRequest - (*ListStaticAddressSwapsResponse)(nil), // 76: looprpc.ListStaticAddressSwapsResponse - (*StaticAddressSummaryRequest)(nil), // 77: looprpc.StaticAddressSummaryRequest - (*StaticAddressSummaryResponse)(nil), // 78: looprpc.StaticAddressSummaryResponse - (*Deposit)(nil), // 79: looprpc.Deposit - (*StaticAddressWithdrawal)(nil), // 80: looprpc.StaticAddressWithdrawal - (*StaticAddressLoopInSwap)(nil), // 81: looprpc.StaticAddressLoopInSwap - (*StaticAddressLoopInRequest)(nil), // 82: looprpc.StaticAddressLoopInRequest - (*StaticAddressLoopInResponse)(nil), // 83: looprpc.StaticAddressLoopInResponse - (*AssetLoopOutRequest)(nil), // 84: looprpc.AssetLoopOutRequest - (*AssetRfqInfo)(nil), // 85: looprpc.AssetRfqInfo - (*FixedPoint)(nil), // 86: looprpc.FixedPoint - (*AssetLoopOutInfo)(nil), // 87: looprpc.AssetLoopOutInfo - nil, // 88: looprpc.LiquidityParameters.EasyAssetParamsEntry - (*lnrpc.OpenChannelRequest)(nil), // 89: lnrpc.OpenChannelRequest - (*swapserverrpc.RouteHint)(nil), // 90: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 91: lnrpc.OutPoint + (*RecoverRequest)(nil), // 39: looprpc.RecoverRequest + (*RecoverResponse)(nil), // 40: looprpc.RecoverResponse + (*L402Token)(nil), // 41: looprpc.L402Token + (*LoopStats)(nil), // 42: looprpc.LoopStats + (*GetInfoRequest)(nil), // 43: looprpc.GetInfoRequest + (*GetInfoResponse)(nil), // 44: looprpc.GetInfoResponse + (*GetLiquidityParamsRequest)(nil), // 45: looprpc.GetLiquidityParamsRequest + (*LiquidityParameters)(nil), // 46: looprpc.LiquidityParameters + (*EasyAssetAutoloopParams)(nil), // 47: looprpc.EasyAssetAutoloopParams + (*LiquidityRule)(nil), // 48: looprpc.LiquidityRule + (*SetLiquidityParamsRequest)(nil), // 49: looprpc.SetLiquidityParamsRequest + (*SetLiquidityParamsResponse)(nil), // 50: looprpc.SetLiquidityParamsResponse + (*SuggestSwapsRequest)(nil), // 51: looprpc.SuggestSwapsRequest + (*Disqualified)(nil), // 52: looprpc.Disqualified + (*SuggestSwapsResponse)(nil), // 53: looprpc.SuggestSwapsResponse + (*AbandonSwapRequest)(nil), // 54: looprpc.AbandonSwapRequest + (*AbandonSwapResponse)(nil), // 55: looprpc.AbandonSwapResponse + (*ListReservationsRequest)(nil), // 56: looprpc.ListReservationsRequest + (*ListReservationsResponse)(nil), // 57: looprpc.ListReservationsResponse + (*ClientReservation)(nil), // 58: looprpc.ClientReservation + (*InstantOutRequest)(nil), // 59: looprpc.InstantOutRequest + (*InstantOutResponse)(nil), // 60: looprpc.InstantOutResponse + (*InstantOutQuoteRequest)(nil), // 61: looprpc.InstantOutQuoteRequest + (*InstantOutQuoteResponse)(nil), // 62: looprpc.InstantOutQuoteResponse + (*ListInstantOutsRequest)(nil), // 63: looprpc.ListInstantOutsRequest + (*ListInstantOutsResponse)(nil), // 64: looprpc.ListInstantOutsResponse + (*InstantOut)(nil), // 65: looprpc.InstantOut + (*NewStaticAddressRequest)(nil), // 66: looprpc.NewStaticAddressRequest + (*NewStaticAddressResponse)(nil), // 67: looprpc.NewStaticAddressResponse + (*ListUnspentDepositsRequest)(nil), // 68: looprpc.ListUnspentDepositsRequest + (*ListUnspentDepositsResponse)(nil), // 69: looprpc.ListUnspentDepositsResponse + (*Utxo)(nil), // 70: looprpc.Utxo + (*WithdrawDepositsRequest)(nil), // 71: looprpc.WithdrawDepositsRequest + (*WithdrawDepositsResponse)(nil), // 72: looprpc.WithdrawDepositsResponse + (*ListStaticAddressDepositsRequest)(nil), // 73: looprpc.ListStaticAddressDepositsRequest + (*ListStaticAddressDepositsResponse)(nil), // 74: looprpc.ListStaticAddressDepositsResponse + (*ListStaticAddressWithdrawalRequest)(nil), // 75: looprpc.ListStaticAddressWithdrawalRequest + (*ListStaticAddressWithdrawalResponse)(nil), // 76: looprpc.ListStaticAddressWithdrawalResponse + (*ListStaticAddressSwapsRequest)(nil), // 77: looprpc.ListStaticAddressSwapsRequest + (*ListStaticAddressSwapsResponse)(nil), // 78: looprpc.ListStaticAddressSwapsResponse + (*StaticAddressSummaryRequest)(nil), // 79: looprpc.StaticAddressSummaryRequest + (*StaticAddressSummaryResponse)(nil), // 80: looprpc.StaticAddressSummaryResponse + (*Deposit)(nil), // 81: looprpc.Deposit + (*StaticAddressWithdrawal)(nil), // 82: looprpc.StaticAddressWithdrawal + (*StaticAddressLoopInSwap)(nil), // 83: looprpc.StaticAddressLoopInSwap + (*StaticAddressLoopInRequest)(nil), // 84: looprpc.StaticAddressLoopInRequest + (*StaticAddressLoopInResponse)(nil), // 85: looprpc.StaticAddressLoopInResponse + (*AssetLoopOutRequest)(nil), // 86: looprpc.AssetLoopOutRequest + (*AssetRfqInfo)(nil), // 87: looprpc.AssetRfqInfo + (*FixedPoint)(nil), // 88: looprpc.FixedPoint + (*AssetLoopOutInfo)(nil), // 89: looprpc.AssetLoopOutInfo + nil, // 90: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 91: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 92: looprpc.RouteHint + (*lnrpc.OutPoint)(nil), // 93: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 89, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 91, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest 0, // 1: looprpc.LoopOutRequest.account_addr_type:type_name -> looprpc.AddressType - 84, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 85, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 90, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint + 86, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 87, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 92, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState 3, // 7: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason - 87, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo + 89, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo 19, // 9: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter 8, // 10: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter 17, // 11: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus 23, // 12: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested 24, // 13: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded 25, // 14: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 90, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 84, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 85, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 90, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 39, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 40, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 40, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 46, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 92, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 86, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 87, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 92, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 41, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 42, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 42, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 48, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule 0, // 23: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 88, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 90, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry 1, // 25: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType 4, // 26: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 44, // 27: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 46, // 27: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters 5, // 28: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason 13, // 29: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest 14, // 30: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 50, // 31: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 56, // 32: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation - 63, // 33: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 68, // 34: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 91, // 35: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 52, // 31: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 58, // 32: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 65, // 33: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 70, // 34: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 93, // 35: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint 6, // 36: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 79, // 37: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 80, // 38: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 81, // 39: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 81, // 37: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 82, // 38: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 83, // 39: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap 6, // 40: looprpc.Deposit.state:type_name -> looprpc.DepositState - 79, // 41: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 81, // 41: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit 7, // 42: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 79, // 43: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 90, // 44: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 79, // 45: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 86, // 46: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 86, // 47: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 45, // 48: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 81, // 43: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 92, // 44: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 81, // 45: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 88, // 46: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 88, // 47: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 47, // 48: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams 13, // 49: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest 14, // 50: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest 16, // 51: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest 18, // 52: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest 21, // 53: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest 26, // 54: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 52, // 55: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 54, // 55: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest 27, // 56: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest 30, // 57: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest 27, // 58: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest @@ -7242,59 +7394,61 @@ var file_client_proto_depIdxs = []int32{ 35, // 61: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest 35, // 62: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest 37, // 63: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 41, // 64: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 11, // 65: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 43, // 66: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 47, // 67: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 49, // 68: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 54, // 69: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 57, // 70: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 59, // 71: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 61, // 72: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 64, // 73: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 66, // 74: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 69, // 75: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 71, // 76: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 73, // 77: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 75, // 78: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 77, // 79: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 82, // 80: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 9, // 81: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 15, // 82: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 15, // 83: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 17, // 84: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 20, // 85: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 22, // 86: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 17, // 87: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 53, // 88: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 29, // 89: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 32, // 90: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 28, // 91: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 31, // 92: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 34, // 93: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 36, // 94: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 36, // 95: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 38, // 96: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 42, // 97: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 12, // 98: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 44, // 99: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 48, // 100: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 51, // 101: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 55, // 102: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 58, // 103: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 60, // 104: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 62, // 105: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 65, // 106: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 67, // 107: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 70, // 108: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 72, // 109: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 74, // 110: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 76, // 111: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 78, // 112: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 83, // 113: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 10, // 114: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 82, // [82:115] is the sub-list for method output_type - 49, // [49:82] is the sub-list for method input_type + 39, // 64: looprpc.SwapClient.Recover:input_type -> looprpc.RecoverRequest + 43, // 65: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 11, // 66: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 45, // 67: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 49, // 68: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 51, // 69: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 56, // 70: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 59, // 71: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 61, // 72: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 63, // 73: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 66, // 74: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 68, // 75: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 71, // 76: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 73, // 77: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 75, // 78: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 77, // 79: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 79, // 80: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 84, // 81: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 9, // 82: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 15, // 83: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 15, // 84: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 17, // 85: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 20, // 86: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 22, // 87: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 17, // 88: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 55, // 89: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 29, // 90: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 32, // 91: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 28, // 92: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 31, // 93: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 34, // 94: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 36, // 95: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 36, // 96: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 38, // 97: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 40, // 98: looprpc.SwapClient.Recover:output_type -> looprpc.RecoverResponse + 44, // 99: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 12, // 100: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 46, // 101: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 50, // 102: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 53, // 103: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 57, // 104: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 60, // 105: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 62, // 106: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 64, // 107: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 67, // 108: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 69, // 109: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 72, // 110: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 74, // 111: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 76, // 112: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 78, // 113: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 80, // 114: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 85, // 115: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 10, // 116: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 83, // [83:117] is the sub-list for method output_type + 49, // [49:83] is the sub-list for method input_type 49, // [49:49] is the sub-list for extension type_name 49, // [49:49] is the sub-list for extension extendee 0, // [0:49] is the sub-list for field type_name @@ -7316,7 +7470,7 @@ func file_client_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_client_proto_rawDesc), len(file_client_proto_rawDesc)), NumEnums: 9, - NumMessages: 80, + NumMessages: 82, NumExtensions: 0, NumServices: 1, }, diff --git a/looprpc/client.pb.gw.go b/looprpc/client.pb.gw.go index c2ef63f88..f80f4ce6b 100644 --- a/looprpc/client.pb.gw.go +++ b/looprpc/client.pb.gw.go @@ -479,6 +479,32 @@ func local_request_SwapClient_GetL402Tokens_1(ctx context.Context, marshaler run } +func request_SwapClient_Recover_0(ctx context.Context, marshaler runtime.Marshaler, client SwapClientClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RecoverRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.Recover(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_SwapClient_Recover_0(ctx context.Context, marshaler runtime.Marshaler, server SwapClientServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RecoverRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.Recover(ctx, &protoReq) + return msg, metadata, err + +} + func request_SwapClient_GetInfo_0(ctx context.Context, marshaler runtime.Marshaler, client SwapClientClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq GetInfoRequest var metadata runtime.ServerMetadata @@ -1175,6 +1201,31 @@ func RegisterSwapClientHandlerServer(ctx context.Context, mux *runtime.ServeMux, }) + mux.Handle("POST", pattern_SwapClient_Recover_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/looprpc.SwapClient/Recover", runtime.WithHTTPPathPattern("/v1/recover")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_SwapClient_Recover_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_SwapClient_Recover_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("GET", pattern_SwapClient_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1905,6 +1956,28 @@ func RegisterSwapClientHandlerClient(ctx context.Context, mux *runtime.ServeMux, }) + mux.Handle("POST", pattern_SwapClient_Recover_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/looprpc.SwapClient/Recover", runtime.WithHTTPPathPattern("/v1/recover")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_SwapClient_Recover_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_SwapClient_Recover_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("GET", pattern_SwapClient_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2307,6 +2380,8 @@ var ( pattern_SwapClient_GetL402Tokens_1 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "lsat", "tokens"}, "")) + pattern_SwapClient_Recover_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "recover"}, "")) + pattern_SwapClient_GetInfo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "loop", "info"}, "")) pattern_SwapClient_StopDaemon_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "daemon", "stop"}, "")) @@ -2367,6 +2442,8 @@ var ( forward_SwapClient_GetL402Tokens_1 = runtime.ForwardResponseMessage + forward_SwapClient_Recover_0 = runtime.ForwardResponseMessage + forward_SwapClient_GetInfo_0 = runtime.ForwardResponseMessage forward_SwapClient_StopDaemon_0 = runtime.ForwardResponseMessage diff --git a/looprpc/client.proto b/looprpc/client.proto index cf14ffa0a..dcf1aed48 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -102,6 +102,12 @@ service SwapClient { */ rpc FetchL402Token (FetchL402TokenRequest) returns (FetchL402TokenResponse); + /* loop: `recover` + Recover restores the local static-address and L402 state from an encrypted + local backup file. + */ + rpc Recover (RecoverRequest) returns (RecoverResponse); + /* loop: `getinfo` GetInfo gets basic information about the loop daemon. */ @@ -1060,6 +1066,48 @@ message FetchL402TokenRequest { message FetchL402TokenResponse { } +message RecoverRequest { + /* + Optional path to the encrypted backup file. If omitted, loopd restores from + the most recent immutable L402 recovery backup in the active network data + directory. + */ + string backup_file = 1; +} + +message RecoverResponse { + /* + The backup file that was restored. + */ + string backup_file = 1; + + /* + Whether a paid L402 token was restored into the local token store. + */ + bool restored_l402 = 2; + + /* + Whether static-address state was restored into loopd and lnd. + */ + bool restored_static_address = 3; + + /* + The restored static address, if any. + */ + string static_address = 4; + + /* + The number of deposits found during best-effort reconciliation. + */ + uint32 num_deposits_found = 5; + + /* + Best-effort deposit reconciliation error text, if reconciliation failed + after state restore completed. + */ + string deposit_reconciliation_error = 6; +} + message L402Token { /* The base macaroon that was baked by the auth server. diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 3d75d15da..76502fa24 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -868,6 +868,39 @@ ] } }, + "/v1/recover": { + "post": { + "summary": "loop: `recover`\nRecover restores the local static-address and L402 state from an encrypted\nlocal backup file.", + "operationId": "SwapClient_Recover", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/looprpcRecoverResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/looprpcRecoverRequest" + } + } + ], + "tags": [ + "SwapClient" + ] + } + }, "/v1/staticaddr": { "post": { "summary": "loop: `static newstaticaddress`\nNewStaticAddress requests a new static address for loop-ins from the server.", @@ -2599,6 +2632,45 @@ "type": "object", "description": "PublishSucceeded is returned by SweepHtlc if publishing was requested in\nSweepHtlcRequest and it succeeded." }, + "looprpcRecoverRequest": { + "type": "object", + "properties": { + "backup_file": { + "type": "string", + "description": "Optional path to the encrypted backup file. If omitted, loopd restores from\nthe most recent immutable L402 recovery backup in the active network data\ndirectory." + } + } + }, + "looprpcRecoverResponse": { + "type": "object", + "properties": { + "backup_file": { + "type": "string", + "description": "The backup file that was restored." + }, + "restored_l402": { + "type": "boolean", + "description": "Whether a paid L402 token was restored into the local token store." + }, + "restored_static_address": { + "type": "boolean", + "description": "Whether static-address state was restored into loopd and lnd." + }, + "static_address": { + "type": "string", + "description": "The restored static address, if any." + }, + "num_deposits_found": { + "type": "integer", + "format": "int64", + "description": "The number of deposits found during best-effort reconciliation." + }, + "deposit_reconciliation_error": { + "type": "string", + "description": "Best-effort deposit reconciliation error text, if reconciliation failed\nafter state restore completed." + } + } + }, "looprpcRouteHint": { "type": "object", "properties": { diff --git a/looprpc/client.yaml b/looprpc/client.yaml index 5213afe4d..88c038b89 100644 --- a/looprpc/client.yaml +++ b/looprpc/client.yaml @@ -33,6 +33,9 @@ http: get: "/v1/l402/tokens" additional_bindings: - get: "/v1/lsat/tokens" + - selector: looprpc.SwapClient.Recover + post: "/v1/recover" + body: "*" - selector: looprpc.SwapClient.GetLiquidityParams get: "/v1/liquidity/params" - selector: looprpc.SwapClient.SetLiquidityParams diff --git a/looprpc/client_grpc.pb.go b/looprpc/client_grpc.pb.go index b03cc9e87..47fdbd0ef 100644 --- a/looprpc/client_grpc.pb.go +++ b/looprpc/client_grpc.pb.go @@ -76,6 +76,10 @@ type SwapClientClient interface { // FetchL402Token fetches an L402 token from the server, this is required in // order to receive reservation notifications from the server. FetchL402Token(ctx context.Context, in *FetchL402TokenRequest, opts ...grpc.CallOption) (*FetchL402TokenResponse, error) + // loop: `recover` + // Recover restores the local static-address and L402 state from an encrypted + // local backup file. + Recover(ctx context.Context, in *RecoverRequest, opts ...grpc.CallOption) (*RecoverResponse, error) // loop: `getinfo` // GetInfo gets basic information about the loop daemon. GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) @@ -312,6 +316,15 @@ func (c *swapClientClient) FetchL402Token(ctx context.Context, in *FetchL402Toke return out, nil } +func (c *swapClientClient) Recover(ctx context.Context, in *RecoverRequest, opts ...grpc.CallOption) (*RecoverResponse, error) { + out := new(RecoverResponse) + err := c.cc.Invoke(ctx, "/looprpc.SwapClient/Recover", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *swapClientClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) { out := new(GetInfoResponse) err := c.cc.Invoke(ctx, "/looprpc.SwapClient/GetInfo", in, out, opts...) @@ -536,6 +549,10 @@ type SwapClientServer interface { // FetchL402Token fetches an L402 token from the server, this is required in // order to receive reservation notifications from the server. FetchL402Token(context.Context, *FetchL402TokenRequest) (*FetchL402TokenResponse, error) + // loop: `recover` + // Recover restores the local static-address and L402 state from an encrypted + // local backup file. + Recover(context.Context, *RecoverRequest) (*RecoverResponse, error) // loop: `getinfo` // GetInfo gets basic information about the loop daemon. GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) @@ -656,6 +673,9 @@ func (UnimplementedSwapClientServer) GetLsatTokens(context.Context, *TokensReque func (UnimplementedSwapClientServer) FetchL402Token(context.Context, *FetchL402TokenRequest) (*FetchL402TokenResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method FetchL402Token not implemented") } +func (UnimplementedSwapClientServer) Recover(context.Context, *RecoverRequest) (*RecoverResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Recover not implemented") +} func (UnimplementedSwapClientServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetInfo not implemented") } @@ -996,6 +1016,24 @@ func _SwapClient_FetchL402Token_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _SwapClient_Recover_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RecoverRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SwapClientServer).Recover(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.SwapClient/Recover", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SwapClientServer).Recover(ctx, req.(*RecoverRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _SwapClient_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetInfoRequest) if err := dec(in); err != nil { @@ -1383,6 +1421,10 @@ var SwapClient_ServiceDesc = grpc.ServiceDesc{ MethodName: "FetchL402Token", Handler: _SwapClient_FetchL402Token_Handler, }, + { + MethodName: "Recover", + Handler: _SwapClient_Recover_Handler, + }, { MethodName: "GetInfo", Handler: _SwapClient_GetInfo_Handler, diff --git a/looprpc/perms.go b/looprpc/perms.go index d646f6671..78952e714 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -151,6 +151,13 @@ var RequiredPermissions = map[string][]bakery.Op{ Entity: "auth", Action: "write", }}, + "/looprpc.SwapClient/Recover": {{ + Entity: "auth", + Action: "write", + }, { + Entity: "loop", + Action: "in", + }}, "/looprpc.SwapClient/SuggestSwaps": {{ Entity: "suggestions", Action: "read", diff --git a/looprpc/swapclient.pb.json.go b/looprpc/swapclient.pb.json.go index ef1297dc3..a168d8152 100644 --- a/looprpc/swapclient.pb.json.go +++ b/looprpc/swapclient.pb.json.go @@ -413,6 +413,31 @@ func RegisterSwapClientJSONCallbacks(registry map[string]func(ctx context.Contex callback(string(respBytes), nil) } + registry["looprpc.SwapClient.Recover"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &RecoverRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewSwapClientClient(conn) + resp, err := client.Recover(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } + registry["looprpc.SwapClient.GetInfo"] = func(ctx context.Context, conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { diff --git a/notifications/manager.go b/notifications/manager.go index bd1ac4170..afe673759 100644 --- a/notifications/manager.go +++ b/notifications/manager.go @@ -26,6 +26,14 @@ const ( // static loop in sweep requests. NotificationTypeStaticLoopInSweepRequest + // NotificationTypeStaticLoopInRiskAccepted is the notification type for + // static loop in confirmation risk acceptance. + NotificationTypeStaticLoopInRiskAccepted + + // NotificationTypeStaticLoopInRiskRejected is the notification type for + // static loop in confirmation risk rejection. + NotificationTypeStaticLoopInRiskRejected + // NotificationTypeUnfinishedSwap is the notification type for unfinished // swap notifications. NotificationTypeUnfinishedSwap @@ -76,6 +84,12 @@ type Manager struct { hasL402 bool subscribers map[NotificationType][]subscriber + + staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification + + staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskRejectedNotification } // NewManager creates a new notification manager. @@ -88,12 +102,21 @@ func NewManager(cfg *Config) *Manager { return &Manager{ cfg: cfg, subscribers: make(map[NotificationType][]subscriber), + staticLoopInRiskAccepted: make( + map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, + ), + staticLoopInRiskRejected: make( + map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskRejectedNotification, + ), } } type subscriber struct { subCtx context.Context recvChan any + swapHash *lntypes.Hash } // SubscribeReservations subscribes to the reservation notifications. @@ -143,6 +166,80 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context, return notifChan } +// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted +// notifications. +func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context, + swapHash lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification { + + notifChan := make( + chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, 1, + ) + + sub := subscriber{ + subCtx: ctx, + recvChan: notifChan, + swapHash: &swapHash, + } + + m.Lock() + m.subscribers[NotificationTypeStaticLoopInRiskAccepted] = append( + m.subscribers[NotificationTypeStaticLoopInRiskAccepted], sub, + ) + if ntfn, ok := m.staticLoopInRiskAccepted[swapHash]; ok { + notifChan <- ntfn + delete(m.staticLoopInRiskAccepted, swapHash) + } + m.Unlock() + + context.AfterFunc(ctx, func() { + m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub) + m.Lock() + delete(m.staticLoopInRiskAccepted, swapHash) + m.Unlock() + close(notifChan) + }) + + return notifChan +} + +// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected +// notifications. +func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context, + swapHash lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification { + + notifChan := make( + chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification, 1, + ) + + sub := subscriber{ + subCtx: ctx, + recvChan: notifChan, + swapHash: &swapHash, + } + + m.Lock() + m.subscribers[NotificationTypeStaticLoopInRiskRejected] = append( + m.subscribers[NotificationTypeStaticLoopInRiskRejected], sub, + ) + if ntfn, ok := m.staticLoopInRiskRejected[swapHash]; ok { + notifChan <- ntfn + delete(m.staticLoopInRiskRejected, swapHash) + } + m.Unlock() + + context.AfterFunc(ctx, func() { + m.removeSubscriber(NotificationTypeStaticLoopInRiskRejected, sub) + m.Lock() + delete(m.staticLoopInRiskRejected, swapHash) + m.Unlock() + close(notifChan) + }) + + return notifChan +} + // SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications. func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context, ) <-chan *swapserverrpc.ServerUnfinishedSwapNotification { @@ -303,7 +400,13 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc. recvChan := sub.recvChan.(chan *swapserverrpc. ServerReservationNotification) - recvChan <- reservationNtfn + select { + case recvChan <- reservationNtfn: + case <-sub.subCtx.Done(): + default: + log.Debugf("Dropping reservation " + + "notification for slow subscriber") + } } case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInSweep: // nolint: lll // We'll forward the static loop in sweep request to all @@ -316,7 +419,98 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc. recvChan := sub.recvChan.(chan *swapserverrpc. ServerStaticLoopInSweepNotification) - recvChan <- staticLoopInSweepRequestNtfn + select { + case recvChan <- staticLoopInSweepRequestNtfn: + case <-sub.subCtx.Done(): + } + } + + case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll + // We'll forward the static loop in risk accepted notification to the + // subscriber for the matching swap. + riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted() + m.Lock() + defer m.Unlock() + + var ( + swapHash lntypes.Hash + hasSwapHash bool + ) + if riskAcceptedNtfn != nil { + hash, err := lntypes.MakeHash(riskAcceptedNtfn.SwapHash) + if err != nil { + log.Warnf("Received invalid static loop in risk "+ + "accepted notification: %v", err) + } else { + swapHash = hash + hasSwapHash = true + m.staticLoopInRiskAccepted[hash] = + riskAcceptedNtfn + delete(m.staticLoopInRiskRejected, hash) + } + } + + for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskAccepted] { // nolint: lll + if !hasSwapHash || sub.swapHash == nil || + *sub.swapHash != swapHash { + + continue + } + + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification) + + select { + case recvChan <- riskAcceptedNtfn: + case <-sub.subCtx.Done(): + default: + log.Debugf("Dropping static loop in risk " + + "accepted notification for slow subscriber") + } + } + + case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll + // We'll forward the static loop in risk rejected notification to the + // subscriber for the matching swap. + riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected() + m.Lock() + defer m.Unlock() + + var ( + swapHash lntypes.Hash + hasSwapHash bool + ) + if riskRejectedNtfn != nil { + hash, err := lntypes.MakeHash(riskRejectedNtfn.SwapHash) + if err != nil { + log.Warnf("Received invalid static loop in risk "+ + "rejected notification: %v", err) + } else { + swapHash = hash + hasSwapHash = true + m.staticLoopInRiskRejected[hash] = + riskRejectedNtfn + delete(m.staticLoopInRiskAccepted, hash) + } + } + + for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskRejected] { // nolint: lll + if !hasSwapHash || sub.swapHash == nil || + *sub.swapHash != swapHash { + + continue + } + + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification) + + select { + case recvChan <- riskRejectedNtfn: + case <-sub.subCtx.Done(): + default: + log.Debugf("Dropping static loop in risk " + + "rejected notification for slow subscriber") + } } case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll @@ -330,7 +524,10 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc. recvChan := sub.recvChan.(chan *swapserverrpc. ServerUnfinishedSwapNotification) - recvChan <- unfinishedSwapNtfn + select { + case recvChan <- unfinishedSwapNtfn: + case <-sub.subCtx.Done(): + } } default: diff --git a/notifications/manager_test.go b/notifications/manager_test.go index 9a06503e8..bba076019 100644 --- a/notifications/manager_test.go +++ b/notifications/manager_test.go @@ -18,7 +18,7 @@ import ( var ( testReservationId = []byte{0x01, 0x02} - testReservationId2 = []byte{0x01, 0x02} + testReservationId2 = []byte{0x03, 0x04} ) // mockNotificationsClient implements the NotificationsClient interface for testing. @@ -188,6 +188,368 @@ func getTestNotification(resId []byte) *swapserverrpc.SubscribeNotificationsResp } } +func unfinishedSwapNotification( + swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse { + + return &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_UnfinishedSwap{ + UnfinishedSwap: &swapserverrpc. + ServerUnfinishedSwapNotification{ + SwapHash: swapHash[:], + }, + }, + } +} + +func staticLoopInRiskAcceptedNotification( + swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse { + + return &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ + StaticLoopInRiskAccepted: &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + }, + }, + } +} + +func staticLoopInRiskRejectedNotification( + swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse { + + return &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskRejected{ + StaticLoopInRiskRejected: &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: swapHash[:], + }, + }, + } +} + +type staticLoopInRiskNotification interface { + GetSwapHash() []byte +} + +func assertStaticLoopInRiskNotificationSwapScoped[ + T staticLoopInRiskNotification](t *testing.T, + subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T, + notification func(lntypes.Hash) *swapserverrpc. + SubscribeNotificationsResponse, label string, + swapHashA, swapHashB lntypes.Hash) { + + t.Helper() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChanA := subscribe(mgr, subCtx, swapHashA) + subChanB := subscribe(mgr, subCtx, swapHashB) + + mgr.handleNotification(notification(swapHashA)) + + select { + case received := <-subChanA: + require.Equal(t, swapHashA[:], received.GetSwapHash()) + + case <-time.After(time.Second): + t.Fatalf("did not receive first swap risk %s notification", + label) + } + + select { + case received := <-subChanB: + t.Fatalf("second swap received wrong notification: %x", + received.GetSwapHash()) + + default: + } + + mgr.handleNotification(notification(swapHashB)) + + select { + case received := <-subChanB: + require.Equal(t, swapHashB[:], received.GetSwapHash()) + + case <-time.After(time.Second): + t.Fatalf("did not receive second swap risk %s notification", + label) + } +} + +// TestManager_SlowSubscriberDoesNotBlock tests that a subscriber with a full +// notification channel does not block delivery to other subscribers. +func TestManager_SlowSubscriberDoesNotBlock(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + slowCtx, slowCancel := context.WithCancel(t.Context()) + defer slowCancel() + slowChan := mgr.SubscribeReservations(slowCtx) + + fastCtx, fastCancel := context.WithCancel(t.Context()) + defer fastCancel() + fastChan := mgr.SubscribeReservations(fastCtx) + + firstNotif := getTestNotification(testReservationId) + mgr.handleNotification(firstNotif) + + received := <-fastChan + require.Equal(t, testReservationId, received.ReservationId) + + secondNotif := getTestNotification(testReservationId2) + done := make(chan struct{}) + go func() { + mgr.handleNotification(secondNotif) + close(done) + }() + + require.Eventually(t, func() bool { + select { + case <-done: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond) + + select { + case received = <-fastChan: + require.Equal(t, testReservationId2, received.ReservationId) + + case <-time.After(time.Second): + t.Fatal("fast subscriber did not receive notification") + } + + require.Len(t, slowChan, 1) +} + +// TestManager_UnfinishedSwapNotificationWaitsForSubscriber verifies that +// unfinished swap recovery notifications are not dropped when the local +// subscriber is briefly behind. +func TestManager_UnfinishedSwapNotificationWaitsForSubscriber(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChan := mgr.SubscribeUnfinishedSwaps(subCtx) + + swapHashA := lntypes.Hash{0x02, 0x03} + swapHashB := lntypes.Hash{0x04, 0x05} + + mgr.handleNotification(unfinishedSwapNotification(swapHashA)) + + done := make(chan struct{}) + go func() { + mgr.handleNotification(unfinishedSwapNotification(swapHashB)) + close(done) + }() + + select { + case received := <-subChan: + require.Equal(t, swapHashA[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not receive first unfinished swap notification") + } + + select { + case <-done: + + case <-time.After(time.Second): + t.Fatal("second unfinished swap notification did not unblock") + } + + select { + case received := <-subChan: + require.Equal(t, swapHashB[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("second unfinished swap notification was dropped") + } +} + +// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager +// forwards static loop in risk accepted notifications to subscribers. +func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + swapHash := lntypes.Hash{0x04, 0x05} + + subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash) + + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ + StaticLoopInRiskAccepted: &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not receive risk accepted notification") + } +} + +// TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a +// notification for one swap does not occupy another swap's subscriber channel. +func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) { + t.Parallel() + + assertStaticLoopInRiskNotificationSwapScoped( + t, func(m *Manager, ctx context.Context, + swapHash lntypes.Hash) <-chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification { + + return m.SubscribeStaticLoopInRiskAccepted(ctx, swapHash) + }, staticLoopInRiskAcceptedNotification, "accepted", + lntypes.Hash{0x04, 0x05}, lntypes.Hash{0x06, 0x07}, + ) +} + +// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager +// replays a risk accepted notification that arrives before the swap-specific +// subscriber is registered. +func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + swapHash := lntypes.Hash{0x06, 0x07} + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ + StaticLoopInRiskAccepted: &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not replay risk accepted notification") + } +} + +// TestManager_StaticLoopInRiskRejectedNotification tests that the Manager +// forwards static loop in risk rejected notifications to subscribers. +func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + swapHash := lntypes.Hash{0x08, 0x09} + + subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash) + + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskRejected{ + StaticLoopInRiskRejected: &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not receive risk rejected notification") + } +} + +// TestManager_StaticLoopInRiskRejectedNotificationSwapScoped verifies that a +// notification for one swap does not occupy another swap's subscriber channel. +func TestManager_StaticLoopInRiskRejectedNotificationSwapScoped(t *testing.T) { + t.Parallel() + + assertStaticLoopInRiskNotificationSwapScoped( + t, func(m *Manager, ctx context.Context, + swapHash lntypes.Hash) <-chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification { + + return m.SubscribeStaticLoopInRiskRejected(ctx, swapHash) + }, staticLoopInRiskRejectedNotification, "rejected", + lntypes.Hash{0x08, 0x09}, lntypes.Hash{0x0a, 0x0b}, + ) +} + +// TestManager_StaticLoopInRiskRejectedNotificationReplay tests that the Manager +// replays a risk rejected notification that arrives before the swap-specific +// subscriber is registered. +func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + swapHash := lntypes.Hash{0x0a, 0x0b} + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskRejected{ + StaticLoopInRiskRejected: &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not replay risk rejected notification") + } +} + // TestManager_Backoff verifies that repeated failures in // subscribeNotifications cause the Manager to space out subscription attempts // via a predictable incremental backoff. diff --git a/recovery/README.md b/recovery/README.md new file mode 100644 index 000000000..05281381c --- /dev/null +++ b/recovery/README.md @@ -0,0 +1,336 @@ +# Recovery Package + +This package implements local recovery for Loop's static-address and L402 +state. + +## Goal + +Recovery is generation-based. In this package, a generation is anchored by: + +- one paid L402 token +- the static-address parameters tied to that L402 + +The current V0 static-address implementation represents a generation locally as +one concrete static address. The backup stores the fields needed to recreate +that concrete address today and also stores the stable receive/change +key-family metadata planned multi-address recovery will scan from later. The +backup itself is not rewritten when later code issues more addresses. + +The recovery flow is designed to let a fresh or repaired Loop instance rebuild +that generation after local disk loss, data-directory replacement, or partial +corruption. + +Recovery uses a single immutable backup per L402 generation. Once written, +that backup file is never updated in place. + +## Backup Model + +The daemon writes at most one encrypted backup file for each paid L402 token +ID: + +`/L402_backup__.enc` + +In the normal layout this resolves inside the active network-specific Loop data +directory, for example: + +`~/.loop/mainnet/L402_backup_1776159001000000000_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.enc` + +If `loop recover` is called without `--backup_file`, Loop scans the active +network directory for files with this name shape. It decrypts candidates with +the local lnd-derived key, filters them by network and filename/payload token +ID, selects the candidate with the latest timestamp in its filename, and then +runs full payload validation before writing any restored state. + +## What Is Backed Up + +Each encrypted backup stores: + +- a backup format version +- the Bitcoin network +- the paid L402 token ID +- the paid L402 token creation time +- the raw paid `l402.token` file +- the static-address protocol version +- the L402-bound server pubkey +- the static-address client pubkey +- the static-address expiry +- the legacy concrete static-address client key family +- the planned multi-address receive key family +- the planned multi-address change key family +- the legacy first height +- the multi-address first height + +The static-address fields are written once. The L402-bound server pubkey, +protocol version, expiry, planned multi-address receive/change key families, +Bitcoin network, and multi-address first height are the stable address-space +metadata for future scanning. The stored client pubkey, legacy client key +family, and legacy first height let the current V0 restore path find the +matching wallet child and recreate the one concrete static-address row. + +Current V0 backups initialize `legacy_first_height` from the legacy concrete +static-address initiation height and `multi_address_first_height` from the +current block height when the backup is written. They are separate fields so +the future multi-address scan floor is independent from the legacy concrete +address import hint. + +The Taproot address string, `pkScript`, and scan lookahead/gap limit are not +backed up. The address and `pkScript` can be derived from the stored key and +script parameters, and the gap limit is restore policy rather than immutable +backup data. + +The L402 file is preserved as a raw blob so restore remains compatible with the +Aperture token-store file format. + +Deposit FSM state is not serialized into the backup. After restore, the deposit +manager asks lnd for wallet-visible static-address UTXOs and recreates active +deposit state from that view. Historical finalized or spent deposit transitions +are not replayed from the backup. + +## Why Root And Legacy Fields Are Both Stored + +The server pubkey, protocol version, expiry, planned multi-address +receive/change key families, Bitcoin network, and multi-address first height +define the stable fields future restore code will combine with lnd-derived +client keys and a chain scan. They are not used by the current V0 restore path. + +The current V0 restore path recreates the existing concrete address row +directly, so the backup also stores that row's client pubkey, legacy client key +family, and legacy first height. Those fields let restore find the matching +local wallet child and import the concrete address from the right chain height. + +## Encryption Model + +The file is encrypted with `secretbox` using a symmetric key derived from lnd +via `Signer.DeriveSharedKey`. + +The derivation uses: + +- a fixed NUMS public key +- the legacy static-address key family +- key index `0` + +This ties backup decryption to the same lnd seed that controls the static +address keys without introducing a user-managed recovery password in this +implementation. + +Operationally, this means the backup is not standalone. Loop cannot decrypt or +restore it without the backing `lnd` wallet that can derive the same key. A +replacement `lnd` restored from the same seed/key material is sufficient, but an +unrelated `lnd` is not. Keep the encrypted Loop backup together with the +corresponding `lnd` recovery material; the Loop backup file by itself is not +enough to recover static-address access. + +## When Backups Are Written + +The backup is only written once a complete recoverable generation exists. A +complete recoverable generation requires both of the following to exist locally: + +- a paid `l402.token` +- a concrete static address bound to that token + +Pending tokens are not backed up. + +If a valid immutable backup for the current paid token ID and creation time +already exists, backup creation is a no-op. A corrupt or undecryptable file with +the same token ID in its name does not suppress creation of a valid backup. + +## Startup Behavior + +Startup is responsible for materializing the current generation before the +backup is written. + +On startup `loopd`: + +1. creates the recovery service +2. if the install is fresh, attempts to restore the latest selectable backup + from the active network directory +3. if nothing was restored, asks the static-address manager for the current + static address +4. if the address does not exist yet, fetches the paid L402, derives the client + key, requests the static address from the server, imports the tapscript into + lnd, and stores the static-address row +5. writes the immutable backup for the resulting paid-L402/static-address + generation + +This gives recovery the "one backup per L402" property without later backup +refreshes. + +### Existing Users + +For existing users that already have a paid L402 and a concrete static address, +the first startup with the upgraded client backfills the missing immutable +backup for the active generation. + +### Fresh Installs + +For fresh installations, startup first checks whether a selectable immutable +backup exists in the active Loop data directory. + +If one is selected and passes full restore validation, Loop restores it instead +of creating a new paid L402 generation. + +If no backup is restored, startup materializes the initial paid L402 plus +concrete static address so the backup can be written immediately. + +The `loop static new` command is therefore no longer the only creation point. +It returns the current static address and only falls back to on-demand creation +if startup initialization did not complete earlier. + +## Restore Flow + +`loop recover --backup_file ` restores a specific immutable backup. If +`--backup_file` is omitted, Loop uses the same active-directory selection logic +described above and restores the selected backup after full validation. + +Restore performs the following steps: + +1. derive the local encryption key from lnd +2. resolve the explicit backup path or select a backup from the active network + directory +3. read, decrypt, and unmarshal the backup file +4. validate the backup version, Bitcoin network, filename metadata, paid-token + metadata, and required static-address fields +5. reconstruct the concrete static-address parameters and find the matching + client key in lnd before writing token files +6. restore the paid `l402.token` file if it is absent, or verify that an + existing file has identical contents +7. import the tapscript into lnd and create or reuse the local concrete + static-address record +8. if static-address restore fails after token files were written, remove only + the token files written by this restore attempt +9. trigger best-effort deposit reconciliation + +Client-key reconstruction uses the following strategy: + +- scan child indexes `0` through `20` in the legacy static-address client key + family using `DeriveKey` +- accept the child whose derived pubkey matches the backed-up client pubkey + +The multi-address scan-and-rebuild flow is not active yet. The immutable backup +already contains the address-space metadata that flow will need. + +## Future Multi-Address Generation + +The planned multi-address model uses two dedicated client-side key families: + +- `swap.StaticMultiAddressKeyFamily` for externally visible static-address + deposits +- `swap.StaticAddressChangeKeyFamily` for outputs that return value back into + the static-address address space + +The legacy `swap.StaticAddressKeyFamily` remains the V0 concrete static-address +family and the static-address HTLC key family. + +The future `static_addresses` table remains a table of concrete derived +addresses. Each row represents one address child and stores: + +- the client pubkey +- the server pubkey +- the client key family +- the client key index +- the resulting `pkScript` +- the protocol version +- the address initiation height + +The immutable backup does not store every row. Instead it stores the +address-space metadata that allows those rows to be rediscovered by scanning. + +For each future receive or change address: + +1. the client chooses the appropriate key family +2. the client derives the next pubkey from lnd for that family +3. the client combines that pubkey with the L402-bound server pubkey using the + static-address MuSig2 construction for the backed-up protocol version +4. the taproot tweak commits to the static-address timeout leaf +5. the resulting taproot output key yields the final P2TR `pkScript` +6. the concrete child row is stored locally in `static_addresses` + +The client key used in the MuSig2 aggregate key should also be the client's key +in the timeout path for that concrete multi-address output. + +Because the backup is immutable, future restore must regenerate candidate +receive and change children from the backed-up key families and the restored +lnd key material, rescan from the backed-up multi-address first height, and +rebuild local table rows from what is found on chain. The lookahead/gap limit +used during that scan is a restore parameter, not immutable backup data. Restore +must not depend on a mutable "last issued child index" snapshot. + +## Server Proof For Multi-Address Inputs + +For a future static swap or withdrawal that spends multi-address inputs, the +server-side proof model is: + +1. the paid L402 authenticates the request and identifies the generation +2. the L402 selects the fixed generation server pubkey and the fixed + protocol/expiry parameters +3. for each input, the client sends the concrete client pubkey that was used to + construct that input's address +4. the server recomputes the timeout leaf for the backed-up protocol version + and expiry +5. the server recomputes the MuSig2 aggregate key from the concrete client + pubkey for that input, the server pubkey bound to the L402 generation, and + the taproot tweak implied by the timeout leaf +6. the server derives the expected taproot output key and the expected P2TR + `pkScript` +7. the server compares that derived `pkScript` with the prevout `pkScript` of + the input being authorized + +If they match, the input belongs to that L402 generation because the output +commits to the generation's server key and the concrete client pubkey used for +that input. + +This proof is about generation membership, not about proving a particular child +index to the server. The immutable backup therefore only needs the stable +address-space metadata, while exact row discovery remains a client-side wallet +and chain-scan problem. + +## Operational Limits + +Restore in this implementation recreates the V0 one-address model only. + +Some practical consequences follow from that: + +- restoring an older immutable backup is best done into a fresh Loop data + directory, or into a directory that already contains the same token and + static-address row +- only one concrete static address can be recreated directly by this restore + code +- conflicting local `l402.token` contents or a different existing + static-address row cause restore to fail rather than overwrite local state +- active deposits are rebuilt best-effort from wallet reconciliation, not by + replaying every stored deposit transition + +## Why The Backup Is Immutable + +The multi-address work needs recovery to be based on stable root material, not +on mutable local cursor snapshots. + +Using one immutable backup per L402 enforces that discipline: + +- the backup must describe a recoverable generation root +- restore must be able to rediscover state from deterministic wallet- and + chain-derived scanning +- later address issuance must not depend on backup files being rewritten + +That discipline keeps later address issuance independent from backup-file +rewrites. + +## Package Boundaries + +This package owns: + +- backup payload definition +- backup encryption and decryption +- immutable backup-file discovery and selection +- paid L402 token-file backup and restore +- V0 static-address key re-derivation and restore orchestration +- static-address metadata fields for future multi-address restore +- post-restore deposit reconciliation orchestration + +This package does not own: + +- CLI command handling +- gRPC transport +- the static-address server protocol +- the future multi-address scanning implementation +- `loopd` startup wiring diff --git a/recovery/service.go b/recovery/service.go new file mode 100644 index 000000000..b7f453e0c --- /dev/null +++ b/recovery/service.go @@ -0,0 +1,1243 @@ +package recovery + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/aperture/l402" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" + staticaddrversion "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lncfg" + "github.com/lightningnetwork/lnd/lntypes" + "golang.org/x/crypto/nacl/secretbox" + "gopkg.in/macaroon.v2" +) + +const ( + backupVersion = 1 + + backupBaseName = "L402_backup" + + backupFileExt = ".enc" + + // backupKeyScanLimit is the highest legacy client-family child index + // scanned when reconstructing the static-address client key. + backupKeyScanLimit = 20 + + paidTokenFileName = "l402.token" + + pendingTokenFileName = "l402.token.pending" +) + +// backupKeyLocator identifies the lnd key used only for deriving the local +// backup encryption key. The encrypted backup stays tied to the same lnd seed +// material without adding a separate user-managed password. +var backupKeyLocator = keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 0, +} + +// backupMagic prefixes encrypted backup files so corrupt or unrelated files can +// be rejected before attempting to unmarshal JSON payloads. +var backupMagic = []byte("loopbak1") + +// StaticAddressManager is the subset of static-address behavior required for +// creating and restoring recovery backups. +type StaticAddressManager interface { + // GetStaticAddressParameters returns the concrete legacy static address + // row that is paired with the current paid L402 generation. + GetStaticAddressParameters(context.Context) (*address.Parameters, error) + + // RestoreAddress recreates that concrete address row and imports its + // tapscript into lnd. The bool reports whether local static-address state + // changed, allowing restore responses to stay idempotent. + RestoreAddress(context.Context, + *address.Parameters) (*btcutil.AddressTaproot, bool, error) + + // CurrentHeight returns the manager's current chain height, which is + // stored as the future multi-address scan floor for this generation. + CurrentHeight() int32 +} + +// DepositManager is the subset of deposit-manager behavior required to +// reconcile deposits after restore. +type DepositManager interface { + // ReconcileDeposits asks lnd for wallet-visible static-address UTXOs and + // rebuilds deposit FSM state for anything not already tracked. + ReconcileDeposits(context.Context) (int, error) +} + +// RecoverResult describes the outcome of a restore attempt. +type RecoverResult struct { + BackupFile string + StaticAddress string + RestoredStaticAddress bool + RestoredL402 bool + NumDepositsFound int + DepositReconciliationError string +} + +// Service coordinates creation and restoration of encrypted local recovery +// backups for Loop static-address and L402 state. +type Service struct { + dataDir string + network string + signer lndclient.SignerClient + walletKit lndclient.WalletKitClient + staticAddressManager StaticAddressManager + depositManager DepositManager +} + +type backupPayload struct { + Version uint32 `json:"version"` + Network string `json:"network"` + L402TokenID string `json:"l402_token_id"` + L402TokenCreatedAt int64 `json:"l402_token_created_at"` + StaticAddress *staticAddressBackup `json:"static_address,omitempty"` + TokenFiles []*l402TokenFileEntry `json:"token_files,omitempty"` +} + +// staticAddressBackup contains the legacy single-address data that can be +// restored directly today, plus the stable per-L402 multi-address/change branch +// fields future multi-address recovery will scan from. +type staticAddressBackup struct { + ProtocolVersion uint32 `json:"protocol_version"` + ClientPubKey []byte `json:"client_pubkey,omitempty"` + ServerPubKey []byte `json:"server_pubkey"` + Expiry uint32 `json:"expiry"` + LegacyClientKeyFamily int32 `json:"legacy_client_key_family,omitempty"` + MainKeyFamily int32 `json:"main_key_family"` + ChangeKeyFamily int32 `json:"change_key_family"` + LegacyFirstHeight int32 `json:"legacy_first_height,omitempty"` + MultiAddressFirstHeight int32 `json:"multi_address_first_height,omitempty"` +} + +type l402TokenFileEntry struct { + Name string `json:"name"` + Data []byte `json:"data"` +} + +type currentTokenState struct { + TokenID string + TokenCreatedAt int64 + TokenFiles []*l402TokenFileEntry +} + +type tokenRestoreResult struct { + restored bool + writtenPaths []string +} + +type paidTokenMetadata struct { + tokenID string + tokenCreatedAt int64 +} + +type backupFileDetails struct { + tokenID string + titleTimestamp int64 +} + +// NewService constructs a recovery service for a specific loop network data +// directory. +func NewService(dataDir, network string, signer lndclient.SignerClient, + walletKit lndclient.WalletKitClient, + staticAddressManager StaticAddressManager, + depositManager DepositManager) *Service { + + return &Service{ + dataDir: dataDir, + network: network, + signer: signer, + walletKit: walletKit, + staticAddressManager: staticAddressManager, + depositManager: depositManager, + } +} + +// WriteBackup writes an encrypted backup file for the current paid-L402 / +// static-address generation. It returns an empty path when there is no +// complete recoverable generation yet, or when the current L402 already has an +// immutable backup on disk. +func (s *Service) WriteBackup(ctx context.Context) (string, error) { + // A backup is immutable and generation-based, so first collect enough + // state to prove the current generation is complete: one paid L402 token + // plus one concrete static address bound to that token. + payload, hasState, err := s.buildPayload(ctx) + if err != nil || !hasState { + return "", err + } + + // We need the derived key before checking for existing backups because a + // filename match alone is not enough. A stale or corrupt file with the same + // token ID must not suppress writing a valid backup. + key, err := s.deriveEncryptionKey(ctx) + if err != nil { + return "", err + } + + // If a valid backup for the exact token creation time already exists, the + // generation is already protected and must not be rewritten. + if backupFile, err := findValidBackupFileForToken( + s.dataDir, key, s.network, payload.L402TokenID, + payload.L402TokenCreatedAt, + ); err != nil { + return "", err + } else if backupFile != "" { + return "", nil + } + + fileName := backupFilePath( + s.dataDir, payload.L402TokenID, payload.L402TokenCreatedAt, + ) + + // The plaintext is never written to disk. It is marshaled in memory, + // encrypted with the lnd-derived key, then atomically installed. + plaintext, err := json.Marshal(payload) + if err != nil { + return "", err + } + + encrypted, err := encryptBackupPayload(key, plaintext) + if err != nil { + return "", err + } + + err = writeFileAtomically(fileName, encrypted) + if err != nil { + return "", err + } + + return fileName, nil +} + +// RestoreLatestOnFreshInstall restores the most recent local backup only when +// loopd has no local token files or static-address state yet. It returns the +// restore result together with a boolean indicating whether a restore was +// actually performed. +func (s *Service) RestoreLatestOnFreshInstall(ctx context.Context) ( + *RecoverResult, bool, error) { + + // Automatic startup restore is intentionally limited to a truly fresh Loop + // directory so it never overwrites an existing token or static address. + freshInstall, err := s.isFreshInstall(ctx) + if err != nil { + return nil, false, err + } + if !freshInstall { + return nil, false, nil + } + + result, err := s.Restore(ctx, "") + switch { + case err == nil: + return result, true, nil + + case errors.Is(err, os.ErrNotExist): + // A fresh install without backup files should continue normal startup + // initialization and create a new paid-L402/static-address generation. + return nil, false, nil + + default: + return nil, false, err + } +} + +// Restore restores the local static-address and L402 state from an encrypted +// backup file. If backupFile is empty, the most recent immutable generation +// backup in the active network directory is used. +func (s *Service) Restore(ctx context.Context, backupFile string) ( + *RecoverResult, error) { + + // Restores use the same lnd-derived key as backup creation. This makes the + // backup useful only with the original lnd seed material. + key, err := s.deriveEncryptionKey(ctx) + if err != nil { + return nil, err + } + + // An explicit path is validated for backup filename shape. An empty path + // means "scan the active network directory and pick the latest candidate". + fileName, err := s.resolveBackupFile(key, backupFile) + if err != nil { + return nil, err + } + + // Decrypt and validate the complete backup before touching local token or + // static-address state. + encrypted, err := os.ReadFile(fileName) + if err != nil { + return nil, err + } + + plaintext, err := decryptBackupPayload(key, encrypted) + if err != nil { + return nil, err + } + + var payload backupPayload + err = json.Unmarshal(plaintext, &payload) + if err != nil { + return nil, err + } + + err = payload.validateNetwork(s.network) + if err != nil { + return nil, err + } + + fileDetails, _ := parseBackupFileName(filepath.Base(fileName)) + err = payload.validateRecoverableGeneration(fileDetails) + if err != nil { + return nil, err + } + + result := &RecoverResult{ + BackupFile: fileName, + } + + var restoreParams *address.Parameters + if payload.StaticAddress != nil { + // Reconstruct and validate the concrete static-address parameters up + // front. If the backed-up key cannot be derived from lnd, no token file + // is written. + restoreParams, err = s.prepareStaticAddressRestore( + ctx, payload.StaticAddress, + ) + if err != nil { + return nil, err + } + } + + tokenRestore, err := s.restoreTokenFiles(payload.TokenFiles) + if err != nil { + return nil, err + } + result.RestoredL402 = tokenRestore.restored + + if restoreParams != nil { + addr, restored, err := s.restorePreparedStaticAddress( + ctx, restoreParams, + ) + if err != nil { + // Token files are restored before the address so the local L402 is + // available to code that validates the generation. If the address + // restore fails, remove only files written by this restore attempt. + rollbackErr := cleanupRestoredTokenFiles( + tokenRestore.writtenPaths, + ) + if rollbackErr != nil { + return nil, fmt.Errorf("unable to restore static "+ + "address: %w (also failed to roll back "+ + "restored token files: %v)", err, + rollbackErr) + } + + return nil, err + } + + result.StaticAddress = addr + result.RestoredStaticAddress = restored + } + + if payload.StaticAddress != nil && s.depositManager != nil { + // Deposit history is not serialized in the backup. After the address + // is restored, reconciliation asks lnd for current UTXOs and recreates + // missing deposit FSMs best-effort. + numDeposits, err := s.depositManager.ReconcileDeposits(ctx) + if err != nil { + result.DepositReconciliationError = err.Error() + } else { + result.NumDepositsFound = numDeposits + } + } + + return result, nil +} + +func (p *backupPayload) validateNetwork(currentNetwork string) error { + // These checks validate the envelope-level metadata before any generation + // contents are trusted. + switch { + case p.Version != backupVersion: + return fmt.Errorf("unsupported backup version %d", p.Version) + + case p.Network == "": + return fmt.Errorf("backup file is missing a network") + + case p.L402TokenID == "": + return fmt.Errorf("backup file is missing an L402 token ID") + + case p.Network != currentNetwork: + return fmt.Errorf("backup file network %s does not match "+ + "daemon network %s", p.Network, currentNetwork) + } + + return nil +} + +func (p *backupPayload) validateRecoverableGeneration( + fileDetails *backupFileDetails) error { + + // When the caller knows the filename metadata, require it to match the + // payload. This keeps the immutable filename and encrypted contents bound + // to the same L402 generation. + if fileDetails != nil { + if p.L402TokenID != fileDetails.tokenID { + return fmt.Errorf("backup file token ID %s does not match "+ + "payload token ID %s", fileDetails.tokenID, + p.L402TokenID) + } + + if p.L402TokenCreatedAt != fileDetails.titleTimestamp { + return fmt.Errorf("backup file timestamp %d does not "+ + "match payload L402 creation time %d", + fileDetails.titleTimestamp, p.L402TokenCreatedAt) + } + } + + if len(p.TokenFiles) == 0 { + return fmt.Errorf("backup file is missing paid L402 token data") + } + + if p.StaticAddress == nil { + return fmt.Errorf("backup file is missing static address " + + "parameters") + } + + // The raw token file is the source of truth for the paid L402. Decode its + // metadata and make sure it matches the generation named by the payload. + metadata, err := validatePaidTokenFiles(p.TokenFiles) + if err != nil { + return err + } + + if metadata.tokenID != p.L402TokenID { + return fmt.Errorf("backup L402 token ID %s does not match "+ + "payload token ID %s", metadata.tokenID, p.L402TokenID) + } + + if metadata.tokenCreatedAt != p.L402TokenCreatedAt { + return fmt.Errorf("backup L402 token creation time %d does "+ + "not match payload creation time %d", + metadata.tokenCreatedAt, p.L402TokenCreatedAt) + } + + return nil +} + +func (s *Service) buildPayload(ctx context.Context) (*backupPayload, bool, + error) { + + // Backups are only meaningful after the token payment completed. Pending + // L402 tokens can still change and do not define an immutable generation. + tokenState, err := s.currentPaidToken() + if err != nil { + return nil, false, err + } + if tokenState == nil || s.staticAddressManager == nil { + return nil, false, nil + } + + payload := &backupPayload{ + Version: backupVersion, + Network: s.network, + L402TokenID: tokenState.TokenID, + L402TokenCreatedAt: tokenState.TokenCreatedAt, + TokenFiles: tokenState.TokenFiles, + } + + // The current static-address row supplies the legacy concrete address that + // this implementation can restore today. The same payload also stores the + // deterministic families and scan floor future multi-address recovery will + // use without rewriting this backup. + addrParams, err := s.staticAddressManager.GetStaticAddressParameters(ctx) + switch { + case err == nil: + multiAddressFirstHeight := s.staticAddressManager.CurrentHeight() + if multiAddressFirstHeight <= 0 { + return nil, false, fmt.Errorf( + "invalid multi-address first height %d", + multiAddressFirstHeight, + ) + } + + payload.StaticAddress = &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey. + SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey. + SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: int32( + addrParams.KeyLocator.Family, + ), + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: multiAddressFirstHeight, + } + + case errors.Is(err, address.ErrNoStaticAddress): + // The current L402 does not have a complete static-address + // generation yet, so there is nothing immutable to back up. + return nil, false, nil + + default: + return nil, false, err + } + + hasState := payload.StaticAddress != nil && len(payload.TokenFiles) > 0 + + return payload, hasState, nil +} + +func (s *Service) currentPaidToken() (*currentTokenState, error) { + tokenStore, err := l402.NewFileStore(s.dataDir) + if err != nil { + return nil, err + } + + token, err := tokenStore.CurrentToken() + switch { + case err == nil: + + case errors.Is(err, l402.ErrNoToken): + return nil, nil + + default: + return nil, err + } + + // Only fully paid tokens define an immutable recoverable generation. + if token.Preimage == (lntypes.Preimage{}) { + return nil, nil + } + + // Preserve the exact token file bytes instead of reserializing the token. + // That keeps restore compatible with Aperture's token-store format. + tokenID, err := decodeTokenID(token) + if err != nil { + return nil, err + } + + path := filepath.Join(s.dataDir, paidTokenFileName) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + return nil, err + } + + return ¤tTokenState{ + TokenID: tokenID, + TokenCreatedAt: token.TimeCreated.UnixNano(), + TokenFiles: []*l402TokenFileEntry{{ + Name: paidTokenFileName, + Data: data, + }}, + }, nil +} + +func decodeTokenID(token *l402.Token) (string, error) { + identifier, err := l402.DecodeIdentifier( + bytes.NewReader(token.BaseMacaroon().Id()), + ) + if err != nil { + return "", err + } + + return identifier.TokenID.String(), nil +} + +func (s *Service) readTokenFiles() ([]*l402TokenFileEntry, error) { + path := filepath.Join(s.dataDir, paidTokenFileName) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + return nil, err + } + + return []*l402TokenFileEntry{{ + Name: paidTokenFileName, + Data: data, + }}, nil +} + +func (s *Service) resolveBackupFile(key [32]byte, backupFile string) (string, + error) { + + if backupFile != "" { + // Explicit restore still requires the immutable backup filename format + // so payload validation can compare encrypted contents to the title. + return validateBackupFilePath(backupFile) + } + + return latestBackupFilePath(s.dataDir, key, s.network) +} + +func validateBackupFilePath(path string) (string, error) { + cleanPath := lncfg.CleanAndExpandPath(path) + if _, ok := parseBackupFileName(filepath.Base(cleanPath)); !ok { + return "", fmt.Errorf("invalid backup file path %q", path) + } + + return cleanPath, nil +} + +type backupSelection struct { + fileName string + tokenID string + sortTimestamp int64 +} + +func latestBackupFilePath(dataDir string, key [32]byte, + network string) (string, error) { + + dirEntries, err := os.ReadDir(dataDir) + if err != nil { + return "", err + } + + var ( + latestSelection *backupSelection + firstErr error + ) + + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + + // Only files using the immutable backup name shape participate in + // automatic selection; everything else in the data dir is ignored. + details, ok := parseBackupFileName(entry.Name()) + if !ok { + continue + } + + path := filepath.Join(dataDir, entry.Name()) + payload, err := readBackupPayload(key, path) + if err != nil { + // Keep scanning so one corrupt or wrong-key backup does not hide a + // valid older backup. + if firstErr == nil { + firstErr = err + } + continue + } + err = payload.validateNetwork(network) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if payload.L402TokenID != details.tokenID { + // The filename token ID is part of the immutable generation + // identity, so mismatches are treated as invalid candidates. + if firstErr == nil { + firstErr = fmt.Errorf("backup file %s token id "+ + "mismatch", path) + } + continue + } + + selection := &backupSelection{ + fileName: path, + tokenID: details.tokenID, + sortTimestamp: details.titleTimestamp, + } + + if latestSelection == nil || + selection.sortTimestamp > latestSelection.sortTimestamp || + (selection.sortTimestamp == latestSelection.sortTimestamp && + selection.tokenID > latestSelection.tokenID) { + + latestSelection = selection + } + } + + if latestSelection != nil { + return latestSelection.fileName, nil + } + if firstErr != nil { + return "", firstErr + } + + return "", os.ErrNotExist +} + +func backupFilePath(dataDir, tokenID string, tokenCreatedAt int64) string { + return filepath.Join(dataDir, backupFileName(tokenID, tokenCreatedAt)) +} + +func backupFileName(tokenID string, tokenCreatedAt int64) string { + return fmt.Sprintf( + "%s_%019d_%s%s", backupBaseName, tokenCreatedAt, tokenID, + backupFileExt, + ) +} + +func backupFileTokenID(name string) (string, bool) { + details, ok := parseBackupFileName(name) + if !ok { + return "", false + } + + return details.tokenID, true +} + +func parseBackupFileName(name string) (*backupFileDetails, bool) { + if !strings.HasPrefix(name, backupBaseName+"_") || + !strings.HasSuffix(name, backupFileExt) { + + return nil, false + } + + remainder := strings.TrimSuffix( + strings.TrimPrefix(name, backupBaseName+"_"), backupFileExt, + ) + + parts := strings.SplitN(remainder, "_", 2) + if len(parts) != 2 { + return nil, false + } + + titleTimestamp, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return nil, false + } + tokenID := parts[1] + + _, err = l402.MakeIDFromString(tokenID) + if err != nil { + return nil, false + } + + return &backupFileDetails{ + tokenID: tokenID, + titleTimestamp: titleTimestamp, + }, true +} + +func findValidBackupFileForToken(dataDir string, key [32]byte, network, + tokenID string, tokenCreatedAt int64) (string, error) { + + dirEntries, err := os.ReadDir(dataDir) + if err != nil { + return "", err + } + + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + + // Search by token ID first, then decrypt to verify the candidate is a + // valid backup for this exact paid-token generation. + details, ok := parseBackupFileName(entry.Name()) + if !ok || details.tokenID != tokenID { + continue + } + + path := filepath.Join(dataDir, entry.Name()) + payload, err := readBackupPayload(key, path) + if err != nil { + // Invalid same-token files are ignored so WriteBackup can replace a + // corrupt placeholder with a real backup. + continue + } + + err = payload.validateNetwork(network) + if err != nil { + continue + } + + err = payload.validateRecoverableGeneration(details) + if err != nil { + continue + } + + if payload.L402TokenCreatedAt != tokenCreatedAt { + continue + } + + return path, nil + } + + return "", nil +} + +func readBackupPlaintext(key [32]byte, path string) ([]byte, error) { + ciphertext, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + return decryptBackupPayload(key, ciphertext) +} + +func readBackupPayload(key [32]byte, path string) (*backupPayload, error) { + plaintext, err := readBackupPlaintext(key, path) + if err != nil { + return nil, err + } + + var payload backupPayload + err = json.Unmarshal(plaintext, &payload) + if err != nil { + return nil, err + } + + return &payload, nil +} + +func (s *Service) prepareStaticAddressRestore(ctx context.Context, + backup *staticAddressBackup) (*address.Parameters, error) { + + // This phase only validates and reconstructs parameters. The token store + // and static-address DB are not modified until all backup fields prove + // internally consistent and derivable from lnd. + if s.staticAddressManager == nil { + return nil, fmt.Errorf("static address restore is unavailable") + } + + if !staticaddrversion.AddressProtocolVersion( + backup.ProtocolVersion, + ).Valid() { + + return nil, fmt.Errorf("invalid static address protocol version %d", + backup.ProtocolVersion) + } + + serverPubKey, err := btcec.ParsePubKey(backup.ServerPubKey) + if err != nil { + return nil, err + } + + clientPubKey, locator, err := s.resolveClientKey(ctx, backup) + if err != nil { + return nil, err + } + + // PkScript is intentionally omitted here. RestoreAddress derives it again + // from the reconstructed script and rejects mismatches when one is supplied. + return &address.Parameters{ + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + Expiry: backup.Expiry, + KeyLocator: locator, + ProtocolVersion: staticaddrversion.AddressProtocolVersion( + backup.ProtocolVersion, + ), + InitiationHeight: backup.LegacyFirstHeight, + }, nil +} + +func (s *Service) restoreTokenFiles( + backupFiles []*l402TokenFileEntry) (*tokenRestoreResult, error) { + + if len(backupFiles) == 0 { + return &tokenRestoreResult{}, nil + } + + existingFiles, err := s.readTokenFiles() + if err != nil { + return nil, err + } + + existingByName := make(map[string][]byte, len(existingFiles)) + for _, file := range existingFiles { + existingByName[file.Name] = file.Data + } + + // Accept only the expected token-store file. The backup format must not be + // able to write arbitrary names into the Loop data directory. + backupByName := make(map[string][]byte, len(backupFiles)) + for _, file := range backupFiles { + if !isTokenFileName(file.Name) { + return nil, fmt.Errorf("unexpected token file name %q", + file.Name) + } + + backupByName[file.Name] = file.Data + } + + for name := range existingByName { + if _, ok := backupByName[name]; ok { + continue + } + + return nil, fmt.Errorf("token store already contains "+ + "unexpected file %q", name) + } + + result := &tokenRestoreResult{} + for name, data := range backupByName { + path := filepath.Join(s.dataDir, name) + if current, ok := existingByName[name]; ok { + // Restoring the same generation is idempotent, but conflicting + // local token bytes are never overwritten. + if !bytes.Equal(current, data) { + return nil, fmt.Errorf("token file %q already exists "+ + "with different contents", name) + } + + continue + } + + err := writeFileAtomically(path, data) + if err != nil { + return nil, err + } + result.restored = true + result.writtenPaths = append(result.writtenPaths, path) + } + + return result, nil +} + +func validatePaidTokenFiles( + backupFiles []*l402TokenFileEntry) (*paidTokenMetadata, error) { + + // Decode the backed-up token file enough to prove it is a paid L402 and to + // bind its ID/creation time to the rest of the payload. + var paidTokenData []byte + for _, file := range backupFiles { + if !isTokenFileName(file.Name) { + return nil, fmt.Errorf("unexpected token file name %q", + file.Name) + } + + if paidTokenData != nil { + return nil, fmt.Errorf("backup contains duplicate paid " + + "L402 token data") + } + + paidTokenData = file.Data + } + + if paidTokenData == nil { + return nil, fmt.Errorf("backup file is missing paid L402 token data") + } + + return parsePaidTokenMetadata(paidTokenData) +} + +func parsePaidTokenMetadata(data []byte) (*paidTokenMetadata, error) { + r := bytes.NewReader(data) + + var macLen uint32 + err := binary.Read(r, binary.BigEndian, &macLen) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token macaroon "+ + "length: %w", err) + } + + if uint64(macLen) > uint64(r.Len()) { + return nil, fmt.Errorf("invalid L402 token macaroon length") + } + + macBytes := make([]byte, macLen) + err = binary.Read(r, binary.BigEndian, &macBytes) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token macaroon: %w", + err) + } + + var paymentHash lntypes.Hash + err = binary.Read(r, binary.BigEndian, &paymentHash) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token payment hash: %w", + err) + } + + var preimage lntypes.Preimage + err = binary.Read(r, binary.BigEndian, &preimage) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token preimage: %w", + err) + } + + if preimage == (lntypes.Preimage{}) { + return nil, fmt.Errorf("backup L402 token is not paid") + } + + var amountPaid uint64 + err = binary.Read(r, binary.BigEndian, &amountPaid) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token amount: %w", + err) + } + + var routingFeePaid uint64 + err = binary.Read(r, binary.BigEndian, &routingFeePaid) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token routing fee: %w", + err) + } + + var tokenCreatedAt int64 + err = binary.Read(r, binary.BigEndian, &tokenCreatedAt) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token creation time: %w", + err) + } + + mac := &macaroon.Macaroon{} + err = mac.UnmarshalBinary(macBytes) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal L402 token "+ + "macaroon: %w", err) + } + + identifier, err := l402.DecodeIdentifier(bytes.NewReader(mac.Id())) + if err != nil { + return nil, fmt.Errorf("unable to decode L402 token ID: %w", err) + } + + return &paidTokenMetadata{ + tokenID: identifier.TokenID.String(), + tokenCreatedAt: tokenCreatedAt, + }, nil +} + +func (s *Service) restorePreparedStaticAddress(ctx context.Context, + params *address.Parameters) (string, bool, error) { + + // The address manager owns persistence and lnd tapscript import ordering. + // Recovery only supplies already-validated parameters. + addr, restored, err := s.staticAddressManager.RestoreAddress( + ctx, params, + ) + if err != nil { + return "", false, err + } + + return addr.String(), restored, nil +} + +func cleanupRestoredTokenFiles(paths []string) error { + if len(paths) == 0 { + return nil + } + + // Only remove files created by this restore attempt. Pre-existing matching + // files are never included in paths and are therefore left untouched. + var cleanupErrs []error + for _, path := range paths { + err := os.Remove(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + cleanupErrs = append( + cleanupErrs, fmt.Errorf("remove %s: %w", path, err), + ) + } + } + + return errors.Join(cleanupErrs...) +} + +func (s *Service) resolveClientKey(ctx context.Context, + backup *staticAddressBackup) ( + *btcec.PublicKey, keychain.KeyLocator, error) { + + if len(backup.ClientPubKey) == 0 { + return nil, keychain.KeyLocator{}, fmt.Errorf( + "backup file is missing the static address client pubkey", + ) + } + + if backup.LegacyClientKeyFamily == 0 { + return nil, keychain.KeyLocator{}, fmt.Errorf( + "backup file is missing the legacy static address " + + "client key family", + ) + } + + expectedClientPubKey, err := btcec.ParsePubKey(backup.ClientPubKey) + if err != nil { + return nil, keychain.KeyLocator{}, err + } + + // Older backups do not persist the key index. Scan the legacy static + // address family and accept the child whose pubkey matches the backup. + for idx := 0; idx <= backupKeyScanLimit; idx++ { + candidateLocator := keychain.KeyLocator{ + Family: keychain.KeyFamily(backup.LegacyClientKeyFamily), + Index: uint32(idx), + } + + candidateKey, err := s.walletKit.DeriveKey( + ctx, &candidateLocator, + ) + if err != nil { + continue + } + + if candidateKey.PubKey.IsEqual(expectedClientPubKey) { + return candidateKey.PubKey, candidateLocator, nil + } + } + + return nil, keychain.KeyLocator{}, fmt.Errorf("unable to derive " + + "static address client key from backup") +} + +func (s *Service) deriveEncryptionKey(ctx context.Context) ([32]byte, error) { + // DeriveSharedKey gives both backup and restore the same symmetric key on + // any lnd instance restored from the same seed/key material. + return s.signer.DeriveSharedKey( + ctx, lndclient.SharedKeyNUMS, &backupKeyLocator, + ) +} + +func encryptBackupPayload(key [32]byte, plaintext []byte) ([]byte, error) { + var nonce [24]byte + _, err := rand.Read(nonce[:]) + if err != nil { + return nil, err + } + + cipherText := secretbox.Seal(nil, plaintext, &nonce, &key) + encoded := make([]byte, 0, len(backupMagic)+len(nonce)+len(cipherText)) + encoded = append(encoded, backupMagic...) + encoded = append(encoded, nonce[:]...) + encoded = append(encoded, cipherText...) + + return encoded, nil +} + +func decryptBackupPayload(key [32]byte, ciphertext []byte) ([]byte, error) { + if len(ciphertext) < len(backupMagic)+24 { + return nil, fmt.Errorf("backup file is too short") + } + if !bytes.Equal(ciphertext[:len(backupMagic)], backupMagic) { + return nil, fmt.Errorf("backup file has an unknown format") + } + + var nonce [24]byte + copy(nonce[:], ciphertext[len(backupMagic):len(backupMagic)+24]) + + plaintext, ok := secretbox.Open( + nil, ciphertext[len(backupMagic)+24:], &nonce, &key, + ) + if !ok { + return nil, fmt.Errorf("unable to decrypt backup file") + } + + return plaintext, nil +} + +func writeFileAtomically(path string, data []byte) error { + tempPath := path + ".tmp" + + // Write private files through a temp path so a crash cannot leave a + // partially written backup or token at the final name. + file, err := os.OpenFile( + tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600, + ) + if err != nil { + return err + } + + _, err = file.Write(data) + if err != nil { + _ = file.Close() + _ = os.Remove(tempPath) + + return err + } + + err = file.Sync() + if err != nil { + _ = file.Close() + _ = os.Remove(tempPath) + + return err + } + + err = file.Close() + if err != nil { + _ = os.Remove(tempPath) + + return err + } + + err = os.Rename(tempPath, path) + if err != nil { + _ = os.Remove(tempPath) + } + + return err +} + +func isTokenFileName(name string) bool { + return filepath.Base(name) == name && name == paidTokenFileName +} + +func (s *Service) isFreshInstall(ctx context.Context) (bool, error) { + // Any local token material means this is not safe for automatic restore: + // a paid token would be overwritten, and a pending token may still be in + // the middle of the L402 acquisition flow. + hasTokenFiles, err := hasAnyLocalTokenFiles(s.dataDir) + if err != nil || hasTokenFiles { + return !hasTokenFiles && err == nil, err + } + + if s.staticAddressManager == nil { + return true, nil + } + + // Static-address state also makes the install non-fresh, even if no token + // file exists, because it may represent a partial local generation. + _, err = s.staticAddressManager.GetStaticAddressParameters(ctx) + switch { + case err == nil: + return false, nil + + case errors.Is(err, address.ErrNoStaticAddress): + return true, nil + + default: + return false, err + } +} + +func hasAnyLocalTokenFiles(dataDir string) (bool, error) { + for _, name := range []string{paidTokenFileName, pendingTokenFileName} { + path := filepath.Join(dataDir, name) + _, err := os.Stat(path) + switch { + case err == nil: + return true, nil + + case errors.Is(err, os.ErrNotExist): + continue + + default: + return false, err + } + } + + return false, nil +} diff --git a/recovery/service_test.go b/recovery/service_test.go new file mode 100644 index 000000000..3dacea068 --- /dev/null +++ b/recovery/service_test.go @@ -0,0 +1,2260 @@ +package recovery + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/aperture/l402" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" + staticaddrscript "github.com/lightninglabs/loop/staticaddr/script" + staticaddrversion "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + testutils "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" + "gopkg.in/macaroon.v2" +) + +// TestEncryptDecryptBackupPayload verifies that a recovery backup payload +// round-trips through the secretbox envelope and is not stored as plaintext. +func TestEncryptDecryptBackupPayload(t *testing.T) { + t.Parallel() + + var key [32]byte + copy(key[:], []byte("0123456789abcdefghijklmnopqrstuv")) + + plaintext := []byte("loop recovery backup payload") + + encrypted, err := encryptBackupPayload(key, plaintext) + require.NoError(t, err) + require.NotEqual(t, plaintext, encrypted) + + decrypted, err := decryptBackupPayload(key, encrypted) + require.NoError(t, err) + require.Equal(t, plaintext, decrypted) +} + +// TestBackupEncryptionUsesSignerDerivedKey verifies that backups are encrypted +// with the documented lnd-derived key and can only be restored with that same +// derived key. +func TestBackupEncryptionUsesSignerDerivedKey(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + restoreDir := t.TempDir() + lnd := testutils.NewMockLnd() + signer := &fixedKeySigner{ + key: testBackupKey(1), + } + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Len(t, signer.calls, 1) + require.True(t, signer.calls[0].pubKey.IsEqual(lndclient.SharedKeyNUMS)) + require.Equal(t, backupKeyLocator, *signer.calls[0].locator) + + _, err = readBackupPayload(testBackupKey(2), backupFile) + require.ErrorContains(t, err, "unable to decrypt backup file") + + payload, err := readBackupPayload(testBackupKey(1), backupFile) + require.NoError(t, err) + require.EqualValues(t, backupVersion, payload.Version) + + wrongKeySvc := NewService( + restoreDir, "testnet", &fixedKeySigner{key: testBackupKey(2)}, + lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, nil, + ) + _, err = wrongKeySvc.Restore(context.Background(), backupFile) + require.ErrorContains(t, err, "unable to decrypt backup file") + + rightKeySvc := NewService( + restoreDir, "testnet", &fixedKeySigner{key: testBackupKey(1)}, + lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, nil, + ) + result, err := rightKeySvc.Restore(context.Background(), backupFile) + require.NoError(t, err) + require.True(t, result.RestoredL402) + require.True(t, result.RestoredStaticAddress) +} + +// TestWriteBackupReturnsEmptyWithoutState verifies that no backup is written +// before Loop has both paid L402 state and static-address state. +func TestWriteBackupReturnsEmptyWithoutState(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, backupFile) + require.Empty(t, listBackupFiles(t, dir)) +} + +// TestWriteBackupReturnsEmptyWithTokenOnly verifies that a paid L402 by itself +// does not define a complete static-address recovery generation. +func TestWriteBackupReturnsEmptyWithTokenOnly(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, backupFile) + require.Empty(t, listBackupFiles(t, dir)) +} + +// TestWriteBackupReturnsEmptyWithPendingToken verifies that pending L402 token +// material is not backed up as an immutable recovery generation. +func TestWriteBackupReturnsEmptyWithPendingToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + + writePendingToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, backupFile) + require.Empty(t, listBackupFiles(t, dir)) +} + +// TestWriteBackupIncludesStaticAddressAndPaidToken verifies that a complete +// generation backup contains the expected static-address parameters, exact paid +// L402 token bytes and private file permissions. +func TestWriteBackupIncludesStaticAddressAndPaidToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + currentHeight: 654, + } + + tokenCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 123, time.UTC, + ) + tokenID := writePaidToken(t, dir, 1, tokenCreatedAt) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Equal( + t, backupFilePath(dir, tokenID, tokenCreatedAt.UnixNano()), + backupFile, + ) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + originalToken, err := os.ReadFile(filepath.Join(dir, paidTokenFileName)) + require.NoError(t, err) + + require.EqualValues(t, backupVersion, payload.Version) + require.Equal(t, "testnet", payload.Network) + require.Equal(t, tokenID, payload.L402TokenID) + require.Equal(t, tokenCreatedAt.UnixNano(), payload.L402TokenCreatedAt) + require.NotNil(t, payload.StaticAddress) + require.EqualValues( + t, addrParams.ProtocolVersion, payload.StaticAddress.ProtocolVersion, + ) + require.Equal( + t, addrParams.ClientPubkey.SerializeCompressed(), + payload.StaticAddress.ClientPubKey, + ) + require.Equal( + t, addrParams.ServerPubkey.SerializeCompressed(), + payload.StaticAddress.ServerPubKey, + ) + require.Equal(t, addrParams.Expiry, payload.StaticAddress.Expiry) + require.Equal( + t, int32(addrParams.KeyLocator.Family), + payload.StaticAddress.LegacyClientKeyFamily, + ) + require.Equal( + t, swap.StaticMultiAddressKeyFamily, + payload.StaticAddress.MainKeyFamily, + ) + require.Equal( + t, swap.StaticAddressChangeKeyFamily, + payload.StaticAddress.ChangeKeyFamily, + ) + require.NotEqual( + t, payload.StaticAddress.LegacyClientKeyFamily, + payload.StaticAddress.MainKeyFamily, + ) + require.NotEqual( + t, payload.StaticAddress.LegacyClientKeyFamily, + payload.StaticAddress.ChangeKeyFamily, + ) + require.NotEqual( + t, payload.StaticAddress.MainKeyFamily, + payload.StaticAddress.ChangeKeyFamily, + ) + require.Equal( + t, addrParams.InitiationHeight, + payload.StaticAddress.LegacyFirstHeight, + ) + require.Equal( + t, int32(654), + payload.StaticAddress.MultiAddressFirstHeight, + ) + require.Len(t, payload.TokenFiles, 1) + require.Equal(t, paidTokenFileName, payload.TokenFiles[0].Name) + require.Equal(t, originalToken, payload.TokenFiles[0].Data) + + info, err := os.Stat(backupFile) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +// TestStaticAddressBackupReconstructsLegacyStaticAddress verifies that the +// backed-up legacy client key material reconstructs the original static address +// tapscript and taproot address. +func TestStaticAddressBackupReconstructsLegacyStaticAddress(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 123, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + backupFile, err := svc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + require.NotNil(t, payload.StaticAddress) + + serverPubKey, err := btcec.ParsePubKey( + payload.StaticAddress.ServerPubKey, + ) + require.NoError(t, err) + + clientPubKey, _, err := svc.resolveClientKey(ctx, payload.StaticAddress) + require.NoError(t, err) + + reconstructed, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, + int64(payload.StaticAddress.Expiry), clientPubKey, serverPubKey, + ) + require.NoError(t, err) + + pkScript, err := reconstructed.StaticAddressScript() + require.NoError(t, err) + require.Equal(t, addrParams.PkScript, pkScript) + + expectedAddr, err := taprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), lnd.ChainParams, + ) + require.NoError(t, err) + + reconstructedAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(reconstructed.TaprootKey), lnd.ChainParams, + ) + require.NoError(t, err) + require.Equal(t, expectedAddr.String(), reconstructedAddr.String()) +} + +// TestStaticAddressBackupReconstructsChangeStaticAddress verifies that the +// backed-up change key family can reconstruct the change static address and +// that it is distinct from the legacy main static address. +func TestStaticAddressBackupReconstructsChangeStaticAddress(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + expectedChangeKey, err := lnd.WalletKit.DeriveKey( + ctx, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressChangeKeyFamily), + Index: 0, + }, + ) + require.NoError(t, err) + + expectedChangeStaticAddr, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, + int64(addrParams.Expiry), expectedChangeKey.PubKey, + addrParams.ServerPubkey, + ) + require.NoError(t, err) + + expectedChangePkScript, err := expectedChangeStaticAddr.StaticAddressScript() + require.NoError(t, err) + + expectedChangeAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(expectedChangeStaticAddr.TaprootKey), + lnd.ChainParams, + ) + require.NoError(t, err) + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 123, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + backupFile, err := svc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + require.NotNil(t, payload.StaticAddress) + + serverPubKey, err := btcec.ParsePubKey( + payload.StaticAddress.ServerPubKey, + ) + require.NoError(t, err) + + changeKeyDesc, err := lnd.WalletKit.DeriveKey( + ctx, &keychain.KeyLocator{ + Family: keychain.KeyFamily( + payload.StaticAddress.ChangeKeyFamily, + ), + Index: 0, + }, + ) + require.NoError(t, err) + + reconstructed, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, + int64(payload.StaticAddress.Expiry), changeKeyDesc.PubKey, + serverPubKey, + ) + require.NoError(t, err) + + pkScript, err := reconstructed.StaticAddressScript() + require.NoError(t, err) + require.Equal(t, expectedChangePkScript, pkScript) + + reconstructedAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(reconstructed.TaprootKey), + lnd.ChainParams, + ) + require.NoError(t, err) + require.Equal(t, expectedChangeAddr.String(), reconstructedAddr.String()) + + legacyAddr, err := taprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), lnd.ChainParams, + ) + require.NoError(t, err) + require.NotEqual(t, legacyAddr.String(), reconstructedAddr.String()) +} + +// TestWriteBackupIsImmutablePerL402 verifies that an existing backup for the +// active L402 token prevents rewriting or creating another backup for the same +// generation. +func TestWriteBackupIsImmutablePerL402(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + tokenID := writePaidToken( + t, dir, 2, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + firstBackup, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Equal( + t, + backupFilePath( + dir, tokenID, + time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC). + UnixNano(), + ), + firstBackup, + ) + + secondBackup, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, secondBackup) + require.Equal(t, []string{firstBackup}, listBackupFiles(t, dir)) +} + +// TestWriteBackupIgnoresInvalidSameTokenBackup verifies that a corrupt file with +// the active token ID in its name does not suppress creation of a valid backup. +func TestWriteBackupIgnoresInvalidSameTokenBackup(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + tokenCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ) + tokenID := writePaidToken(t, dir, 3, tokenCreatedAt) + + backupPath := backupFilePath(dir, tokenID, tokenCreatedAt.UnixNano()) + err := os.WriteFile(backupPath, []byte("corrupt backup"), 0600) + require.NoError(t, err) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + writtenBackup, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Equal(t, backupPath, writtenBackup) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupPath) + require.NoError(t, err) + require.Equal(t, tokenID, payload.L402TokenID) + require.Equal(t, tokenCreatedAt.UnixNano(), payload.L402TokenCreatedAt) +} + +// TestRestoreLatestBackupPrefersNewestGeneration verifies that restoring +// without an explicit path selects the newest valid backup generation. +func TestRestoreLatestBackupPrefersNewestGeneration(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + writePaidToken( + t, backupDir, 0x20, + time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + firstBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + writePaidToken( + t, backupDir, 0x10, + time.Date(2026, time.April, 14, 9, 31, 1, 0, time.UTC), + ) + secondBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + copyFile(t, firstBackup, filepath.Join(restoreDir, filepath.Base(firstBackup))) + copyFile( + t, secondBackup, filepath.Join(restoreDir, filepath.Base(secondBackup)), + ) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + result, err := destSvc.Restore(ctx, "") + require.NoError(t, err) + require.Equal(t, filepath.Join( + restoreDir, filepath.Base(secondBackup), + ), result.BackupFile) + require.True(t, result.RestoredL402) + require.True(t, result.RestoredStaticAddress) +} + +// TestRestoreLatestOnFreshInstallUsesLatestTimestampInTitle verifies that +// startup recovery on a fresh install selects the newest timestamped backup +// filename. +func TestRestoreLatestOnFreshInstallUsesLatestTimestampInTitle(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + firstCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ) + writePaidToken(t, backupDir, 0x20, firstCreatedAt) + firstBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + secondCreatedAt := time.Date( + 2026, time.April, 14, 9, 31, 1, 0, time.UTC, + ) + writePaidToken(t, backupDir, 0x10, secondCreatedAt) + secondBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + err = os.Remove(filepath.Join(backupDir, paidTokenFileName)) + require.NoError(t, err) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + destSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + result, restored, err := destSvc.RestoreLatestOnFreshInstall(ctx) + require.NoError(t, err) + require.True(t, restored) + require.Equal(t, secondBackup, result.BackupFile) + + // Keep both variables referenced to make the intended ordering explicit. + require.NotEqual(t, firstBackup, secondBackup) +} + +// TestLatestBackupFilePathSelection verifies latest-backup selection across +// invalid candidates, empty valid sets and timestamp tie-breaks. +func TestLatestBackupFilePathSelection(t *testing.T) { + t.Parallel() + + t.Run("skips invalid newer backups", func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, dir, 0x01, + time.Date(2026, time.April, 14, 9, 30, 1, 0, + time.UTC), + ) + validPath, err := svc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + newer := time.Date( + 2026, time.April, 14, 10, 30, 1, 0, time.UTC, + ).UnixNano() + corruptID := testTokenID(0x02) + err = os.WriteFile( + backupFilePath(dir, corruptID, newer), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + + mismatchNameID := testTokenID(0x03) + writeBackupPayload( + t, svc, dir, mismatchNameID, newer+1, + validBackupPayload(testTokenID(0x04), newer+1), + ) + + wrongNetworkID := testTokenID(0x05) + wrongNetworkPayload := validBackupPayload(wrongNetworkID, + newer+2) + wrongNetworkPayload.Network = "mainnet" + writeBackupPayload( + t, svc, dir, wrongNetworkID, newer+2, + wrongNetworkPayload, + ) + + latestFile, err := latestBackupFilePath(dir, key, "testnet") + require.NoError(t, err) + require.Equal(t, validPath, latestFile) + }) + + t.Run("returns error without valid backup", func(t *testing.T) { + dir := t.TempDir() + lnd := testutils.NewMockLnd() + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + err = os.WriteFile( + backupFilePath(dir, testTokenID(0x01), 1), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + + _, err = latestBackupFilePath(dir, key, "testnet") + require.ErrorContains(t, err, "backup file is too short") + }) + + t.Run("tie breaks by token id", func(t *testing.T) { + dir := t.TempDir() + lnd := testutils.NewMockLnd() + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + timestamp := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ).UnixNano() + lowerID := testTokenID(0x01) + higherID := testTokenID(0x02) + + lowerPath := writeBackupPayload( + t, svc, dir, lowerID, timestamp, + validBackupPayload(lowerID, timestamp), + ) + higherPath := writeBackupPayload( + t, svc, dir, higherID, timestamp, + validBackupPayload(higherID, timestamp), + ) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + latestFile, err := latestBackupFilePath(dir, key, "testnet") + require.NoError(t, err) + require.NotEqual(t, lowerPath, higherPath) + require.Equal(t, higherPath, latestFile) + }) +} + +// TestRestoreStaticAddressAndPaidToken documents the unit-level recovery story: +// a paid L402 generation with static-address parameters is written to an +// encrypted backup, then restored into an empty Loop data directory using the +// same lnd-derived encryption key. The restore must recreate the static address +// with the exact backed-up key material, expiry, protocol version and initiation +// height, write back the exact L402 token bytes, and run deposit reconciliation +// so a recovered daemon can discover funds sent to that static address. The +// live itest should extend this story with confirmed deposits, loop-in change, +// explicit generation switching via loop recover, withdrawals and swaps against +// a running Loop server. +func TestRestoreStaticAddressAndPaidToken(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + sourceStaticMgr, nil, + ) + + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + originalToken, err := os.ReadFile(filepath.Join(backupDir, paidTokenFileName)) + require.NoError(t, err) + + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + depositMgr := &mockDepositManager{ + depositsFound: 3, + } + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, depositMgr, + ) + + result, err := destSvc.Restore(ctx, backupFile) + require.NoError(t, err) + require.Equal(t, backupFile, result.BackupFile) + require.True(t, result.RestoredStaticAddress) + require.True(t, result.RestoredL402) + require.Equal(t, 3, result.NumDepositsFound) + require.Empty(t, result.DepositReconciliationError) + + require.Len(t, destStaticMgr.restoreCalls, 1) + restoredParams := destStaticMgr.restoreCalls[0] + require.True(t, restoredParams.ClientPubkey.IsEqual(addrParams.ClientPubkey)) + require.True(t, restoredParams.ServerPubkey.IsEqual(addrParams.ServerPubkey)) + require.Equal(t, addrParams.Expiry, restoredParams.Expiry) + require.Equal(t, addrParams.PkScript, restoredParams.PkScript) + require.Equal(t, addrParams.KeyLocator, restoredParams.KeyLocator) + require.Equal( + t, addrParams.ProtocolVersion, restoredParams.ProtocolVersion, + ) + require.Equal( + t, addrParams.InitiationHeight, restoredParams.InitiationHeight, + ) + + require.Equal(t, 1, depositMgr.calls) + restoredToken, err := os.ReadFile( + filepath.Join(restoreDir, paidTokenFileName), + ) + require.NoError(t, err) + require.Equal(t, originalToken, restoredToken) + + expectedAddr, err := destStaticMgr.GetTaprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), + ) + require.NoError(t, err) + require.Equal(t, expectedAddr.String(), result.StaticAddress) +} + +// TestRestoreReturnsDepositReconciliationError verifies that restore succeeds +// even when deposit reconciliation fails, while reporting the reconciliation +// error to the caller. +func TestRestoreReturnsDepositReconciliationError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + sourceStaticMgr, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + depositErr := errors.New("reconcile failed") + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, + &mockDepositManager{err: depositErr}, + ) + + result, err := destSvc.Restore(ctx, backupFile) + require.NoError(t, err) + require.Equal(t, depositErr.Error(), result.DepositReconciliationError) + require.Equal(t, 0, result.NumDepositsFound) +} + +// TestRestoreReportsNoStaticAddressChangeForIdempotentRestore verifies that +// restoring the already-present generation reports no L402 or static-address +// change while still returning the recovered address. +func TestRestoreReportsNoStaticAddressChangeForIdempotentRestore(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + sourceStaticMgr, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + writePaidToken( + t, restoreDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + restoreChanged: false, + restoreChangedSet: true, + } + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + result, err := destSvc.Restore(ctx, backupFile) + require.NoError(t, err) + require.False(t, result.RestoredL402) + require.False(t, result.RestoredStaticAddress) + require.NotEmpty(t, result.StaticAddress) + require.Len(t, destStaticMgr.restoreCalls, 1) +} + +// TestRestoreLatestOnFreshInstallSkipsNonFreshInstall verifies that startup +// auto-restore is skipped whenever local token or static-address state already +// makes the data directory non-fresh, even if a valid backup is present. +func TestRestoreLatestOnFreshInstallSkipsNonFreshInstall(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T, string, *testutils.LndMockServices) StaticAddressManager + }{ + { + name: "local paid token", + setup: func(t *testing.T, dir string, + lnd *testutils.LndMockServices) StaticAddressManager { + + writePaidToken( + t, dir, 1, + time.Date(2026, time.April, 14, 9, 30, + 1, 0, time.UTC), + ) + + return &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + }, + }, + { + name: "local pending token", + setup: func(t *testing.T, dir string, + lnd *testutils.LndMockServices) StaticAddressManager { + + writePendingToken( + t, dir, 1, + time.Date(2026, time.April, 14, 9, 30, + 1, 0, time.UTC), + ) + + return &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + }, + }, + { + name: "local static address", + setup: func(t *testing.T, _ string, + lnd *testutils.LndMockServices) StaticAddressManager { + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, + 144, 321, + ) + + return &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + staticMgr := test.setup(t, dir, lnd) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + staticMgr, nil, + ) + backupTokenID := testTokenID(0x20) + writeBackupPayload( + t, svc, dir, backupTokenID, 2, + validBackupPayload(backupTokenID, 2), + ) + + result, restored, err := svc.RestoreLatestOnFreshInstall( + ctx, + ) + require.NoError(t, err) + require.False(t, restored) + require.Nil(t, result) + }) + } +} + +// TestRestoreRejectsNetworkMismatch verifies that a backup from another Loop +// network cannot be restored into the current network data directory. +func TestRestoreRejectsNetworkMismatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + sourceSvc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ), + }, nil, + ) + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + restoreSvc := NewService( + t.TempDir(), "mainnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "does not match") +} + +// TestRestoreRejectsInvalidPayloadMetadata verifies that unsupported or +// incomplete backup payload metadata is rejected before any local state is +// restored. +func TestRestoreRejectsInvalidPayloadMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*backupPayload) + expectedErr string + }{ + { + name: "unsupported version", + mutate: func(payload *backupPayload) { + payload.Version = backupVersion + 1 + }, + expectedErr: "unsupported backup version", + }, + { + name: "missing network", + mutate: func(payload *backupPayload) { + payload.Network = "" + }, + expectedErr: "backup file is missing a network", + }, + { + name: "missing token ID", + mutate: func(payload *backupPayload) { + payload.L402TokenID = "" + }, + expectedErr: "backup file is missing an L402 token ID", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + tokenID := testTokenID(0x10) + payload := validBackupPayload(tokenID, 1) + test.mutate(payload) + + backupFile := writeBackupPayload(t, svc, dir, tokenID, 1, payload) + + _, err := svc.Restore(context.Background(), backupFile) + require.ErrorContains(t, err, test.expectedErr) + }) + } +} + +// TestRestoreRejectsIncompleteRecoverableGeneration verifies that restore does +// not accept backups that lack either side of the documented paid-L402/static +// address generation. +func TestRestoreRejectsIncompleteRecoverableGeneration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*backupPayload) + expectedErr string + }{ + { + name: "missing paid token data", + mutate: func(payload *backupPayload) { + payload.TokenFiles = nil + }, + expectedErr: "missing paid L402 token data", + }, + { + name: "missing static address", + mutate: func(payload *backupPayload) { + payload.StaticAddress = nil + }, + expectedErr: "missing static address parameters", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, backupDir, 1, + time.Date( + 2026, time.April, 14, 9, 30, 1, 0, + time.UTC, + ), + ) + + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := sourceSvc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + test.mutate(payload) + mutatedBackup := writeBackupPayload( + t, sourceSvc, backupDir, payload.L402TokenID, + payload.L402TokenCreatedAt, payload, + ) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + _, err = restoreSvc.Restore(ctx, mutatedBackup) + require.ErrorContains(t, err, test.expectedErr) + require.Empty(t, destStaticMgr.restoreCalls) + }) + } +} + +// TestRestoreRejectsTokenMetadataMismatch verifies that the raw token bytes in +// the backup must match the payload's generation metadata before restore writes +// any state. +func TestRestoreRejectsTokenMetadataMismatch(t *testing.T) { + t.Parallel() + + originalCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ) + + tests := []struct { + name string + tokenSeed byte + tokenCreatedAt time.Time + expectedErr string + }{ + { + name: "token ID mismatch", + tokenSeed: 2, + tokenCreatedAt: originalCreatedAt, + expectedErr: "does not match payload token ID", + }, + { + name: "creation time mismatch", + tokenSeed: 1, + tokenCreatedAt: time.Date( + 2026, time.April, 14, 10, 30, 1, 0, + time.UTC, + ), + expectedErr: "does not match payload creation time", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken(t, backupDir, 1, originalCreatedAt) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := sourceSvc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + otherTokenDir := t.TempDir() + writePaidToken( + t, otherTokenDir, test.tokenSeed, + test.tokenCreatedAt, + ) + otherToken, err := os.ReadFile( + filepath.Join(otherTokenDir, paidTokenFileName), + ) + require.NoError(t, err) + + payload.TokenFiles = []*l402TokenFileEntry{{ + Name: paidTokenFileName, + Data: otherToken, + }} + mutatedBackup := writeBackupPayload( + t, sourceSvc, backupDir, payload.L402TokenID, + payload.L402TokenCreatedAt, payload, + ) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + _, err = restoreSvc.Restore(ctx, mutatedBackup) + require.ErrorContains(t, err, test.expectedErr) + require.Empty(t, destStaticMgr.restoreCalls) + + _, err = os.Stat(filepath.Join(restoreDir, paidTokenFileName)) + require.ErrorIs(t, err, os.ErrNotExist) + }) + } +} + +// TestRestoreRejectsExplicitFilenamePayloadTokenIDMismatch verifies that an +// explicit path is held to the same filename/payload token-ID consistency check +// used during latest-backup discovery. +func TestRestoreRejectsExplicitFilenamePayloadTokenIDMismatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, backupDir, 1, + time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := sourceSvc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + mismatchedBackup := writeBackupPayload( + t, sourceSvc, backupDir, testTokenID(2), + payload.L402TokenCreatedAt, payload, + ) + + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, nil, + ) + + _, err = restoreSvc.Restore(ctx, mismatchedBackup) + require.ErrorContains(t, err, "backup file token ID") +} + +// TestPrepareStaticAddressRestoreRejectsInvalidBackup verifies that malformed +// static-address backup fields are rejected before RestoreAddress is called. +func TestPrepareStaticAddressRestoreRejectsInvalidBackup(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + + baseBackup := func() *staticAddressBackup { + return &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey. + SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey. + SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: int32( + addrParams.KeyLocator.Family, + ), + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: addrParams.InitiationHeight, + } + } + + _, unrelatedPubKey := testutils.CreateKey(99) + tests := []struct { + name string + mutate func(*staticAddressBackup) + expectedErr string + }{ + { + name: "invalid protocol version", + mutate: func(backup *staticAddressBackup) { + backup.ProtocolVersion = 99 + }, + expectedErr: "invalid static address protocol version", + }, + { + name: "missing client pubkey", + mutate: func(backup *staticAddressBackup) { + backup.ClientPubKey = nil + }, + expectedErr: "missing the static address client pubkey", + }, + { + name: "missing legacy client key family", + mutate: func(backup *staticAddressBackup) { + backup.LegacyClientKeyFamily = 0 + }, + expectedErr: "missing the legacy static address " + + "client key family", + }, + { + name: "malformed client pubkey", + mutate: func(backup *staticAddressBackup) { + backup.ClientPubKey = []byte{0x01, 0x02} + }, + expectedErr: "public key", + }, + { + name: "malformed server pubkey", + mutate: func(backup *staticAddressBackup) { + backup.ServerPubKey = []byte{0x01, 0x02} + }, + expectedErr: "public key", + }, + { + name: "client key not found", + mutate: func(backup *staticAddressBackup) { + backup.ClientPubKey = unrelatedPubKey. + SerializeCompressed() + }, + expectedErr: "unable to derive static address client key", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + svc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + }, nil, + ) + backup := baseBackup() + test.mutate(backup) + + _, err := svc.prepareStaticAddressRestore(ctx, backup) + require.ErrorContains(t, err, test.expectedErr) + }) + } +} + +// TestRestoreRejectsExplicitPathWithInvalidName verifies that explicit restore +// paths must use the documented backup filename format. +func TestRestoreRejectsExplicitPathWithInvalidName(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + + restoreSvc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + invalidPath := filepath.Join(t.TempDir(), "backup.enc") + err := os.WriteFile(invalidPath, []byte("invalid"), 0600) + require.NoError(t, err) + + _, err = restoreSvc.Restore(ctx, invalidPath) + require.ErrorContains(t, err, "invalid backup file path") +} + +// TestRestoreFailsWithoutStaticAddressManager verifies that backups containing +// static-address state cannot be restored when the daemon has no static-address +// manager configured. +func TestRestoreFailsWithoutStaticAddressManager(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 3, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + restoreSvc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "static address restore is unavailable") +} + +// TestRestoreRejectsDifferentExistingTokenBeforeStaticAddress verifies that a +// conflicting local L402 token blocks restore before static-address state is +// modified. +func TestRestoreRejectsDifferentExistingTokenBeforeStaticAddress(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 3, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + err = os.WriteFile( + filepath.Join(restoreDir, paidTokenFileName), + []byte("conflicting-token"), 0600, + ) + require.NoError(t, err) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "different contents") + require.Empty(t, destStaticMgr.restoreCalls) +} + +// TestRestoreRollsBackTokenFilesOnStaticAddressFailure verifies that token +// files written during restore are removed again if static-address restore +// fails. +func TestRestoreRollsBackTokenFilesOnStaticAddressFailure(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 3, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + restoreErr: errors.New("restore address failed"), + }, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "restore address failed") + + _, err = os.Stat(filepath.Join(restoreDir, paidTokenFileName)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// TestResolveClientKeyScansLegacyClientFamilyReadOnly verifies that locating the +// backed-up static-address client key scans existing keys without advancing the +// wallet's next-key index. +func TestResolveClientKeyScansLegacyClientFamilyReadOnly(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + targetIndex := uint32(5) + addrParams := makeStaticAddressParams( + t, lnd, targetIndex, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + svc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, + staticMgr, nil, + ) + + backup := &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey.SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey.SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: swap.StaticAddressKeyFamily, + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: addrParams.InitiationHeight, + } + + clientKey, locator, err := svc.resolveClientKey(ctx, backup) + require.NoError(t, err) + require.Equal(t, addrParams.KeyLocator, locator) + require.True(t, clientKey.IsEqual(addrParams.ClientPubkey)) + + nextKey, err := lnd.WalletKit.DeriveNextKey( + ctx, swap.StaticAddressKeyFamily, + ) + require.NoError(t, err) + require.EqualValues(t, 0, nextKey.Index) +} + +// TestResolveClientKeyScansLegacyClientFamily verifies that restore can find a +// backed-up static-address client key within the configured legacy client +// family scan range. +func TestResolveClientKeyScansLegacyClientFamily(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + targetIndex := uint32(7) + addrParams := makeStaticAddressParams( + t, lnd, targetIndex, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + svc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, + staticMgr, nil, + ) + + backup := &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey.SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey.SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: swap.StaticAddressKeyFamily, + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: addrParams.InitiationHeight, + } + + clientKey, locator, err := svc.resolveClientKey(ctx, backup) + require.NoError(t, err) + require.Equal(t, addrParams.KeyLocator, locator) + require.True(t, clientKey.IsEqual(addrParams.ClientPubkey)) +} + +// TestResolveClientKeyFamilySelection verifies the V0 restore path scans the +// concrete legacy client-key family, while keeping the future multi-address +// receive/change families out of the lookup. +func TestResolveClientKeyFamilySelection(t *testing.T) { + t.Parallel() + + ctx := context.Background() + targetIndex := uint32(6) + _, expectedPubKey := testutils.CreateKey(200) + + tests := []struct { + name string + backup *staticAddressBackup + expectedLookupFamily int32 + }{ + { + name: "explicit legacy client family wins", + backup: &staticAddressBackup{ + ClientPubKey: expectedPubKey.SerializeCompressed(), + LegacyClientKeyFamily: swap.StaticAddressKeyFamily, + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + }, + expectedLookupFamily: swap.StaticAddressKeyFamily, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + walletKit := &familyScopedWalletKit{ + expectedFamily: test.expectedLookupFamily, + expectedIndex: targetIndex, + expectedPubKey: expectedPubKey, + } + svc := NewService( + t.TempDir(), "testnet", nil, walletKit, nil, nil, + ) + + clientKey, locator, err := svc.resolveClientKey( + ctx, test.backup, + ) + require.NoError(t, err) + require.True(t, clientKey.IsEqual(expectedPubKey)) + require.Equal( + t, keychain.KeyFamily(test.expectedLookupFamily), + locator.Family, + ) + require.Equal(t, targetIndex, locator.Index) + require.NotEmpty(t, walletKit.calls) + + for _, call := range walletKit.calls { + require.Equal( + t, + keychain.KeyFamily(test.expectedLookupFamily), + call.Family, + ) + } + }) + } +} + +// TestRestoreTokenFiles verifies that paid token material is restored exactly +// once and that restoring identical token bytes is idempotent. +func TestRestoreTokenFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + svc := &Service{ + dataDir: dir, + } + + restoreResult, err := svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token", + Data: []byte("paid-token"), + }}) + require.NoError(t, err) + require.True(t, restoreResult.restored) + + paidToken, err := os.ReadFile(filepath.Join(dir, "l402.token")) + require.NoError(t, err) + require.Equal(t, []byte("paid-token"), paidToken) + + restoreResult, err = svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token", + Data: []byte("paid-token"), + }}) + require.NoError(t, err) + require.False(t, restoreResult.restored) +} + +// TestRestoreTokenFilesRejectsDifferentExistingToken verifies that restore +// refuses to overwrite existing paid token material with different bytes. +func TestRestoreTokenFilesRejectsDifferentExistingToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, "l402.token"), []byte("current-token"), 0600, + ) + require.NoError(t, err) + + svc := &Service{ + dataDir: dir, + } + + _, err = svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token", + Data: []byte("backup-token"), + }}) + require.ErrorContains(t, err, "different contents") +} + +// TestRestoreTokenFilesRejectsInvalidName verifies that backup payloads cannot +// write arbitrary or pending-token filenames into the token store. +func TestRestoreTokenFilesRejectsInvalidName(t *testing.T) { + t.Parallel() + + svc := &Service{ + dataDir: t.TempDir(), + } + + _, err := svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token.pending", + Data: []byte("pending-token"), + }}) + require.ErrorContains(t, err, "unexpected token file name") +} + +// TestLatestBackupFilePathIgnoresMalformedNames verifies that files which do +// not match the backup filename grammar are ignored during latest-backup +// selection. +func TestLatestBackupFilePathIgnoresMalformedNames(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + validPath, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + + err = os.WriteFile( + filepath.Join(dir, "L402_backup_not-an-id.enc"), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + err = os.WriteFile( + filepath.Join(dir, backupFileName(stringsOfLength(64), 1)), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + latestFile, err := latestBackupFilePath(dir, key, "testnet") + require.NoError(t, err) + require.Equal(t, validPath, latestFile) +} + +// TestWriteFileAtomically verifies that backup files are written with private +// permissions and that failed atomic writes clean up their temporary files. +func TestWriteFileAtomically(t *testing.T) { + t.Parallel() + + t.Run("uses private permissions", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "backup.enc") + err := writeFileAtomically(path, []byte("backup")) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + }) + + t.Run("cleans temp file on rename error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "backup-target") + err := os.Mkdir(path, 0700) + require.NoError(t, err) + + err = writeFileAtomically(path, []byte("backup")) + require.Error(t, err) + + _, err = os.Stat(path + ".tmp") + require.ErrorIs(t, err, os.ErrNotExist) + }) +} + +var defaultRecoveryServerPubkey = func() *btcec.PublicKey { + _, pubKey := testutils.CreateKey(42) + return pubKey +}() + +type deriveSharedKeyCall struct { + pubKey *btcec.PublicKey + locator *keychain.KeyLocator +} + +type fixedKeySigner struct { + lndclient.SignerClient + + key [32]byte + calls []deriveSharedKeyCall +} + +func (s *fixedKeySigner) DeriveSharedKey(_ context.Context, + pubKey *btcec.PublicKey, locator *keychain.KeyLocator) ([32]byte, + error) { + + call := deriveSharedKeyCall{ + pubKey: pubKey, + } + if locator != nil { + locatorCopy := *locator + call.locator = &locatorCopy + } + s.calls = append(s.calls, call) + + return s.key, nil +} + +type familyScopedWalletKit struct { + lndclient.WalletKitClient + + expectedFamily int32 + expectedIndex uint32 + expectedPubKey *btcec.PublicKey + calls []keychain.KeyLocator +} + +func (w *familyScopedWalletKit) DeriveKey(_ context.Context, + locator *keychain.KeyLocator) (*keychain.KeyDescriptor, error) { + + if locator == nil { + return nil, fmt.Errorf("missing key locator") + } + + w.calls = append(w.calls, *locator) + + if int32(locator.Family) == w.expectedFamily && + locator.Index == w.expectedIndex { + + return &keychain.KeyDescriptor{ + KeyLocator: *locator, + PubKey: w.expectedPubKey, + }, nil + } + + _, pubKey := testutils.CreateKey( + 10_000 + int32(locator.Index) + int32(locator.Family), + ) + + return &keychain.KeyDescriptor{ + KeyLocator: *locator, + PubKey: pubKey, + }, nil +} + +func testBackupKey(seed byte) [32]byte { + var key [32]byte + for idx := range key { + key[idx] = seed + } + + return key +} + +func testTokenID(seed byte) string { + var tokenID l402.TokenID + tokenID[0] = seed + + return tokenID.String() +} + +func validBackupPayload(tokenID string, tokenCreatedAt int64) *backupPayload { + return &backupPayload{ + Version: backupVersion, + Network: "testnet", + L402TokenID: tokenID, + L402TokenCreatedAt: tokenCreatedAt, + } +} + +func writeBackupPayload(t *testing.T, svc *Service, dir, tokenID string, + titleTimestamp int64, payload *backupPayload) string { + + t.Helper() + + plaintext, err := json.Marshal(payload) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + encrypted, err := encryptBackupPayload(key, plaintext) + require.NoError(t, err) + + path := backupFilePath(dir, tokenID, titleTimestamp) + err = os.WriteFile(path, encrypted, 0600) + require.NoError(t, err) + + return path +} + +type mockStaticAddressManager struct { + chainParams *chaincfg.Params + params *address.Parameters + currentHeight int32 + getParamsErr error + restoreErr error + restoreCalls []*address.Parameters + restoreChanged bool + restoreChangedSet bool +} + +func (m *mockStaticAddressManager) GetStaticAddressParameters( + context.Context) (*address.Parameters, error) { + + switch { + case m.getParamsErr != nil: + return nil, m.getParamsErr + + case m.params == nil: + return nil, address.ErrNoStaticAddress + + default: + return cloneAddressParameters(m.params), nil + } +} + +func (m *mockStaticAddressManager) CurrentHeight() int32 { + if m.currentHeight > 0 { + return m.currentHeight + } + if m.params != nil { + return m.params.InitiationHeight + } + + return 0 +} + +func (m *mockStaticAddressManager) GetTaprootAddress(clientPubkey, + serverPubkey *btcec.PublicKey, expiry int64) (*btcutil.AddressTaproot, + error) { + + return taprootAddress( + clientPubkey, serverPubkey, expiry, m.chainParams, + ) +} + +func (m *mockStaticAddressManager) RestoreAddress(_ context.Context, + params *address.Parameters) (*btcutil.AddressTaproot, bool, error) { + + if m.restoreErr != nil { + return nil, false, m.restoreErr + } + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, int64(params.Expiry), + params.ClientPubkey, params.ServerPubkey, + ) + if err != nil { + return nil, false, err + } + pkScript, err := staticAddress.StaticAddressScript() + if err != nil { + return nil, false, err + } + if len(params.PkScript) != 0 && + !bytes.Equal(params.PkScript, pkScript) { + + return nil, false, fmt.Errorf("static address pk script mismatch") + } + + params.PkScript = pkScript + m.restoreCalls = append(m.restoreCalls, cloneAddressParameters(params)) + + changed := true + if m.restoreChangedSet { + changed = m.restoreChanged + } + + addr, err := m.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, int64(params.Expiry), + ) + if err != nil { + return nil, false, err + } + + return addr, changed, nil +} + +type mockDepositManager struct { + depositsFound int + err error + calls int +} + +func (m *mockDepositManager) ReconcileDeposits(context.Context) (int, error) { + m.calls++ + return m.depositsFound, m.err +} + +func makeStaticAddressParams(t *testing.T, lnd *testutils.LndMockServices, + index uint32, serverPubKey *btcec.PublicKey, expiry uint32, + initiationHeight int32) *address.Parameters { + + t.Helper() + + keyDesc, err := lnd.WalletKit.DeriveKey( + context.Background(), &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: index, + }, + ) + require.NoError(t, err) + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, int64(expiry), keyDesc.PubKey, + serverPubKey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + return &address.Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: serverPubKey, + Expiry: expiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: staticaddrversion.ProtocolVersion_V0, + InitiationHeight: initiationHeight, + } +} + +func cloneAddressParameters(params *address.Parameters) *address.Parameters { + if params == nil { + return nil + } + + return &address.Parameters{ + ClientPubkey: params.ClientPubkey, + ServerPubkey: params.ServerPubkey, + Expiry: params.Expiry, + PkScript: slices.Clone(params.PkScript), + KeyLocator: params.KeyLocator, + ProtocolVersion: params.ProtocolVersion, + InitiationHeight: params.InitiationHeight, + } +} + +func taprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, expiry int64, + chainParams *chaincfg.Params) (*btcutil.AddressTaproot, error) { + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, expiry, clientPubkey, serverPubkey, + ) + if err != nil { + return nil, err + } + + return btcutil.NewAddressTaproot( + schnorr.SerializePubKey(staticAddress.TaprootKey), chainParams, + ) +} + +func writePaidToken(t *testing.T, dir string, seed byte, + createdAt time.Time) string { + + t.Helper() + + return writeTokenFile( + t, filepath.Join(dir, paidTokenFileName), seed, createdAt, true, + ) +} + +func writePendingToken(t *testing.T, dir string, seed byte, + createdAt time.Time) string { + + t.Helper() + + return writeTokenFile( + t, filepath.Join(dir, "l402.token.pending"), seed, createdAt, false, + ) +} + +func writeTokenFile(t *testing.T, path string, seed byte, createdAt time.Time, + paid bool) string { + + t.Helper() + + var ( + paymentHash lntypes.Hash + tokenID l402.TokenID + preimage lntypes.Preimage + ) + paymentHash[0] = seed + tokenID[0] = seed + if paid { + preimage[0] = seed + } + + var idBytes bytes.Buffer + err := l402.EncodeIdentifier(&idBytes, &l402.Identifier{ + Version: l402.LatestVersion, + PaymentHash: paymentHash, + TokenID: tokenID, + }) + require.NoError(t, err) + + mac, err := macaroon.New( + []byte("loop-recovery-test-root-key"), + idBytes.Bytes(), "loop.test", macaroon.LatestVersion, + ) + require.NoError(t, err) + + macBytes, err := mac.MarshalBinary() + require.NoError(t, err) + + var serialized bytes.Buffer + err = binary.Write(&serialized, binary.BigEndian, uint32(len(macBytes))) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, macBytes) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, paymentHash) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, preimage) + require.NoError(t, err) + err = binary.Write( + &serialized, binary.BigEndian, lnwire.MilliSatoshi(seed)*1000, + ) + require.NoError(t, err) + err = binary.Write( + &serialized, binary.BigEndian, lnwire.MilliSatoshi(seed)*10, + ) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, createdAt.UnixNano()) + require.NoError(t, err) + + err = os.WriteFile(path, serialized.Bytes(), 0600) + require.NoError(t, err) + + return tokenID.String() +} + +func listBackupFiles(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + var files []string + for _, entry := range entries { + if _, ok := backupFileTokenID(entry.Name()); ok { + files = append(files, filepath.Join(dir, entry.Name())) + } + } + + slices.Sort(files) + return files +} + +func copyFile(t *testing.T, src, dest string) { + t.Helper() + + data, err := os.ReadFile(src) + require.NoError(t, err) + + err = os.WriteFile(dest, data, 0600) + require.NoError(t, err) +} + +func stringsOfLength(length int) string { + return string(bytes.Repeat([]byte("a"), length)) +} diff --git a/staticaddr/address/manager.go b/staticaddr/address/manager.go index 3382210d2..18f94adb1 100644 --- a/staticaddr/address/manager.go +++ b/staticaddr/address/manager.go @@ -3,7 +3,9 @@ package address import ( "bytes" "context" + "errors" "fmt" + "strings" "sync" "sync/atomic" @@ -29,6 +31,12 @@ const ( maxStaticAddressCSVExpiry = uint32(200 * 144) ) +var ( + // ErrNoStaticAddress is returned when no static address parameters are + // present in the store. + ErrNoStaticAddress = errors.New("no static address parameters found") +) + // ManagerConfig holds the configuration for the address manager. type ManagerConfig struct { // AddressClient is the client that communicates with the loop server @@ -79,6 +87,13 @@ func NewManager(cfg *ManagerConfig, currentHeight int32) (*Manager, error) { return m, nil } +// CurrentHeight returns the manager's latest observed block height. Recovery +// stores this height as the scan floor for the future multi-address generation +// rooted in the current paid L402. +func (m *Manager) CurrentHeight() int32 { + return m.currentHeight.Load() +} + // Run runs the address manager. func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { newBlockChan, newBlockErrChan, err := @@ -122,11 +137,27 @@ func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, return nil, 0, err } if len(addresses) > 0 { - clientPubKey := addresses[0].ClientPubkey - serverPubKey := addresses[0].ServerPubkey - expiry := int64(addresses[0].Expiry) + addrParams := addresses[0] + clientPubKey := addrParams.ClientPubkey + serverPubKey := addrParams.ServerPubkey + expiry := int64(addrParams.Expiry) + m.Unlock() - defer m.Unlock() + // Re-import the tapscript even when the address row already exists. + // This keeps the call idempotent while repairing cases where restore + // or startup left the DB populated before lnd imported the script. + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, expiry, clientPubKey, + serverPubKey, + ) + if err != nil { + return nil, 0, err + } + + _, err = m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return nil, 0, err + } address, err := m.GetTaprootAddress( clientPubKey, serverPubKey, expiry, @@ -209,24 +240,20 @@ func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, ), InitiationHeight: m.currentHeight.Load(), } - err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) + + // Import before persisting the address row. If lnd rejects the + // script import, a later startup/recovery attempt should still see a + // clean missing-address state instead of a DB-only static address. + _, err = m.importAddressTapscript(ctx, staticAddress) if err != nil { return nil, 0, err } - // Import the static address tapscript into our lnd wallet, so we can - // track unspent outputs of it. - tapScript := input.TapscriptFullTree( - staticAddress.InternalPubKey, *staticAddress.TimeoutLeaf, - ) - addr, err := m.cfg.WalletKit.ImportTaprootScript(ctx, tapScript) + err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) if err != nil { return nil, 0, err } - log.Infof("Imported static address taproot script to lnd wallet: %v", - addr) - address, err := m.GetTaprootAddress( clientPubKey.PubKey, serverPubKey, int64(serverParams.Expiry), ) @@ -272,6 +299,158 @@ func validateServerAddressParams( return nil } +// RestoreAddress recreates a static address record locally and makes sure the +// corresponding tapscript is imported into lnd. Recovery passes already-derived +// address parameters here; this method owns the DB/import ordering so a failed +// lnd import cannot leave behind an untracked DB-only address. If the same +// address already exists locally, the call is idempotent. +func (m *Manager) RestoreAddress(ctx context.Context, + addrParams *Parameters) (*btcutil.AddressTaproot, bool, error) { + + if addrParams == nil { + return nil, false, fmt.Errorf("missing static address parameters") + } + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(addrParams.Expiry), + addrParams.ClientPubkey, addrParams.ServerPubkey, + ) + if err != nil { + return nil, false, err + } + + pkScript, err := staticAddress.StaticAddressScript() + if err != nil { + return nil, false, err + } + + if len(addrParams.PkScript) != 0 && + !bytes.Equal(addrParams.PkScript, pkScript) { + + return nil, false, fmt.Errorf("static address pk script mismatch") + } + + addrParams.PkScript = pkScript + if addrParams.InitiationHeight <= 0 { + addrParams.InitiationHeight = m.currentHeight.Load() + } + + m.Lock() + existing, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + m.Unlock() + + return nil, false, err + } + + var changed bool + switch { + case len(existing) == 0: + // Import before creating the restored DB row. If import fails, the + // next recovery attempt should still treat the address as missing + // instead of getting stuck on an untracked DB-only address. + _, err := m.importAddressTapscript(ctx, staticAddress) + if err != nil { + m.Unlock() + + return nil, false, err + } + + err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) + if err != nil { + m.Unlock() + + return nil, false, err + } + changed = true + + case len(existing) > 1: + m.Unlock() + + return nil, false, fmt.Errorf("more than one static address found") + + case !sameAddressParameters(existing[0], addrParams): + m.Unlock() + + return nil, false, fmt.Errorf("existing static address differs from " + + "backup") + + default: + m.Unlock() + + // The DB row already matches the backup. Re-import anyway so + // restore is idempotent and can repair a prior partial restore where + // lnd never learned the tapscript. + imported, err := m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return nil, false, err + } + + changed = imported + + addr, err := m.GetTaprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), + ) + if err != nil { + return nil, false, err + } + + return addr, changed, nil + } + m.Unlock() + + addr, err := m.GetTaprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), + ) + if err != nil { + return nil, false, err + } + + return addr, changed, nil +} + +func (m *Manager) importAddressTapscript(ctx context.Context, + staticAddress *script.StaticAddress) (bool, error) { + + // Import the static address tapscript into our lnd wallet, so we can + // track unspent outputs of it. + tapScript := input.TapscriptFullTree( + staticAddress.InternalPubKey, *staticAddress.TimeoutLeaf, + ) + addr, err := m.cfg.WalletKit.ImportTaprootScript(ctx, tapScript) + if err != nil { + // Restoring into an lnd instance that already imported the script is + // expected. Treat the duplicate import as success. + if strings.Contains(err.Error(), "already exists") { + log.Infof("Static address tapscript already imported") + return false, nil + } + + return false, err + } + + log.Infof("Imported static address taproot script to lnd wallet: %v", + addr) + + return true, nil +} + +func sameAddressParameters(a, b *Parameters) bool { + if a == nil || b == nil { + return false + } + + return a.ClientPubkey.IsEqual(b.ClientPubkey) && + a.ServerPubkey.IsEqual(b.ServerPubkey) && + a.Expiry == b.Expiry && + bytes.Equal(a.PkScript, b.PkScript) && + a.KeyLocator == b.KeyLocator && + a.ProtocolVersion == b.ProtocolVersion && + a.InitiationHeight == b.InitiationHeight +} + // GetTaprootAddress returns a taproot address for the given client and server // public keys and expiry. func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, @@ -337,7 +516,9 @@ func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, return taprootAddress, filteredUtxos, nil } -// GetStaticAddressParameters returns the parameters of the static address. +// GetStaticAddressParameters returns the single concrete static-address row +// currently supported by the legacy address manager. Recovery treats the row as +// the V0 address that can be backed up and restored directly. func (m *Manager) GetStaticAddressParameters(ctx context.Context) (*Parameters, error) { @@ -347,7 +528,7 @@ func (m *Manager) GetStaticAddressParameters(ctx context.Context) (*Parameters, } if len(params) == 0 { - return nil, fmt.Errorf("no static address parameters found") + return nil, ErrNoStaticAddress } return params[0], nil diff --git a/staticaddr/address/manager_test.go b/staticaddr/address/manager_test.go index 5881bf848..9ea6f1248 100644 --- a/staticaddr/address/manager_test.go +++ b/staticaddr/address/manager_test.go @@ -3,12 +3,15 @@ package address import ( "context" "encoding/hex" + "errors" "testing" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" @@ -33,6 +36,18 @@ type mockStaticAddressClient struct { mock.Mock } +type failingImportWalletKit struct { + lndclient.WalletKitClient + + err error +} + +func (w *failingImportWalletKit) ImportTaprootScript(context.Context, + *waddrmgr.Tapscript) (btcutil.Address, error) { + + return nil, w.err +} + func (m *mockStaticAddressClient) ServerStaticAddressLoopIn(ctx context.Context, in *swapserverrpc.ServerStaticAddressLoopInRequest, opts ...grpc.CallOption) ( @@ -128,6 +143,171 @@ func TestManager(t *testing.T) { // The expiry has to match. require.EqualValues(t, defaultExpiry, expiry) + + storedParams, err := testContext.manager.GetStaticAddressParameters(ctxb) + require.NoError(t, err) + require.EqualValues( + t, swap.StaticAddressKeyFamily, storedParams.KeyLocator.Family, + ) +} + +// TestRestoreAddress verifies that restoring an address recreates the same +// static address locally without requiring a server call. +func TestRestoreAddress(t *testing.T) { + ctxb := t.Context() + + testContext := NewAddressManagerTestContext(t) + + keyDesc, err := testContext.mockLnd.WalletKit.DeriveKey( + ctxb, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 7, + }, + ) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + keyDesc.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + addressParams := &Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: 0, + InitiationHeight: 123, + } + + taprootAddress, restored, err := testContext.manager.RestoreAddress( + ctxb, addressParams, + ) + require.NoError(t, err) + require.True(t, restored) + + expectedAddress, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(staticAddress.TaprootKey), + testContext.manager.cfg.ChainParams, + ) + require.NoError(t, err) + require.Equal(t, expectedAddress.String(), taprootAddress.String()) + + storedParams, err := testContext.manager.GetStaticAddressParameters(ctxb) + require.NoError(t, err) + require.True(t, sameAddressParameters(storedParams, addressParams)) + + taprootAddress, restored, err = testContext.manager.RestoreAddress( + ctxb, addressParams, + ) + require.NoError(t, err) + require.False(t, restored) + require.Equal(t, expectedAddress.String(), taprootAddress.String()) +} + +// TestRestoreAddressImportFailureDoesNotCreateRow verifies that a failed lnd +// tapscript import leaves no static-address DB row behind, so a later retry can +// restore cleanly. +func TestRestoreAddressImportFailureDoesNotCreateRow(t *testing.T) { + ctxb := t.Context() + + testContext := NewAddressManagerTestContext(t) + + keyDesc, err := testContext.mockLnd.WalletKit.DeriveKey( + ctxb, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 7, + }, + ) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + keyDesc.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + addressParams := &Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: 0, + InitiationHeight: 123, + } + + importErr := errors.New("import failed") + testContext.manager.cfg.WalletKit = &failingImportWalletKit{ + WalletKitClient: testContext.mockLnd.WalletKit, + err: importErr, + } + + _, _, err = testContext.manager.RestoreAddress(ctxb, addressParams) + require.ErrorIs(t, err, importErr) + + _, err = testContext.manager.GetStaticAddressParameters(ctxb) + require.ErrorIs(t, err, ErrNoStaticAddress) + + testContext.manager.cfg.WalletKit = testContext.mockLnd.WalletKit + _, restored, err := testContext.manager.RestoreAddress( + ctxb, addressParams, + ) + require.NoError(t, err) + require.True(t, restored) +} + +// TestRestoreAddressRejectsDifferentInitiationHeight verifies that a restore +// request with the same address material but a different initiation height is +// rejected instead of being treated as idempotent. +func TestRestoreAddressRejectsDifferentInitiationHeight(t *testing.T) { + ctxb := t.Context() + + testContext := NewAddressManagerTestContext(t) + + keyDesc, err := testContext.mockLnd.WalletKit.DeriveKey( + ctxb, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 7, + }, + ) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + keyDesc.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + addressParams := &Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: 0, + InitiationHeight: 123, + } + + _, _, err = testContext.manager.RestoreAddress(ctxb, addressParams) + require.NoError(t, err) + + differentHeight := *addressParams + differentHeight.InitiationHeight = 456 + + _, _, err = testContext.manager.RestoreAddress(ctxb, &differentHeight) + require.ErrorContains(t, err, "existing static address differs from backup") } // TestNewAddressValidatesServerResponse tests that the untrusted diff --git a/staticaddr/deposit/actions.go b/staticaddr/deposit/actions.go index 362417e7d..437c3ac2a 100644 --- a/staticaddr/deposit/actions.go +++ b/staticaddr/deposit/actions.go @@ -161,14 +161,25 @@ func (f *FSM) WaitForExpirySweepAction(ctx context.Context, // FinalizeDepositAction is the final action after a withdrawal. It signals to // the manager that the deposit has been swept and the FSM can be removed. -func (f *FSM) FinalizeDepositAction(ctx context.Context, +func (f *FSM) FinalizeDepositAction(_ context.Context, _ fsm.EventContext) fsm.EventType { - select { - case <-ctx.Done(): - return fsm.OnError + outpoint := f.deposit.OutPoint - case f.finalizedDepositChan <- f.deposit.OutPoint: - return fsm.NoOp - } + // The finalization notification only tells the manager to remove the + // deposit from its active set. Send it asynchronously so a busy manager + // loop can't stall withdrawal confirmation while deposit locks are held. + go func() { + select { + case <-f.quitChan: + // The deposit is already in a final state. If shutdown wins + // this race, startup recovery will skip it instead of + // re-adding it to the active set. + return + + case f.finalizedDepositChan <- outpoint: + } + }() + + return fsm.NoOp } diff --git a/staticaddr/deposit/actions_test.go b/staticaddr/deposit/actions_test.go new file mode 100644 index 000000000..8c0211211 --- /dev/null +++ b/staticaddr/deposit/actions_test.go @@ -0,0 +1,135 @@ +package deposit + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + "github.com/stretchr/testify/require" +) + +// TestFinalizeDepositActionDoesNotBlock ensures the final cleanup notification +// does not block the withdrawal completion path while the manager loop is busy. +func TestFinalizeDepositActionDoesNotBlock(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + } + + depositFSM := &FSM{ + deposit: &Deposit{ + OutPoint: outpoint, + }, + quitChan: make(chan struct{}), + finalizedDepositChan: make(chan wire.OutPoint), + } + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- depositFSM.FinalizeDepositAction(ctx, nil) + }() + + select { + case result := <-resultChan: + require.Equal(t, fsm.NoOp, result) + + case <-time.After(100 * time.Millisecond): + t.Fatal("FinalizeDepositAction blocked on manager cleanup") + } + + select { + case gotOutpoint := <-depositFSM.finalizedDepositChan: + require.Equal(t, outpoint, gotOutpoint) + + case <-time.After(time.Second): + t.Fatal("finalization cleanup notification was not delivered") + } +} + +// TestFinalizeDepositActionIgnoresRequestCancellation ensures the cleanup +// notification is tied to the FSM lifetime, not the caller's request context. +func TestFinalizeDepositActionIgnoresRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + quitChan := make(chan struct{}) + defer close(quitChan) + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + } + + depositFSM := &FSM{ + deposit: &Deposit{ + OutPoint: outpoint, + }, + quitChan: quitChan, + finalizedDepositChan: make(chan wire.OutPoint), + } + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- depositFSM.FinalizeDepositAction(ctx, nil) + }() + + select { + case result := <-resultChan: + require.Equal(t, fsm.NoOp, result) + + case <-time.After(100 * time.Millisecond): + t.Fatal("FinalizeDepositAction blocked on manager cleanup") + } + + cancel() + + select { + case gotOutpoint := <-depositFSM.finalizedDepositChan: + require.Equal(t, outpoint, gotOutpoint) + + case <-time.After(time.Second): + t.Fatal("finalization cleanup notification was dropped after " + + "request cancellation") + } +} + +// TestFinalizeDepositActionIgnoresCanceledContext ensures the final cleanup +// notification is still queued even if the caller's context is already done. +func TestFinalizeDepositActionIgnoresCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + quitChan := make(chan struct{}) + defer close(quitChan) + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 3, + } + + depositFSM := &FSM{ + deposit: &Deposit{ + OutPoint: outpoint, + }, + quitChan: quitChan, + finalizedDepositChan: make(chan wire.OutPoint), + } + + result := depositFSM.FinalizeDepositAction(ctx, nil) + require.Equal(t, fsm.NoOp, result) + + select { + case gotOutpoint := <-depositFSM.finalizedDepositChan: + require.Equal(t, outpoint, gotOutpoint) + + case <-time.After(time.Second): + t.Fatal("finalization cleanup notification was dropped for " + + "an already-canceled request context") + } +} diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 4cb64bc95..9da88c749 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -29,6 +29,10 @@ func (r *ID) FromByteSlice(b []byte) error { // Deposit bundles an utxo at a static address together with manager-relevant // data. +// +// Lock order: if both Manager.mu and a Deposit lock are needed, acquire +// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a +// Deposit lock. type Deposit struct { sync.Mutex @@ -45,7 +49,8 @@ type Deposit struct { Value btcutil.Amount // ConfirmationHeight is the absolute height at which the deposit was - // first confirmed. + // first confirmed. A value of zero means the deposit is still + // unconfirmed. ConfirmationHeight int64 // TimeOutSweepPkScript is the pk script that is used to sweep the @@ -69,15 +74,22 @@ func (d *Deposit) IsInFinalState() bool { d.Lock() defer d.Unlock() + // Replaced is inactive from the deposit FSM's point of view. The manager may + // still revive the same record if lnd reports the exact outpoint again after + // a transient wallet-view miss. return d.state == Expired || d.state == Withdrawn || d.state == LoopedIn || d.state == HtlcTimeoutSwept || - d.state == ChannelPublished + d.state == ChannelPublished || d.state == Replaced } func (d *Deposit) IsExpired(currentHeight, expiry uint32) bool { d.Lock() defer d.Unlock() + if d.ConfirmationHeight <= 0 { + return false + } + return currentHeight >= uint32(d.ConfirmationHeight)+expiry } diff --git a/staticaddr/deposit/deposit_test.go b/staticaddr/deposit/deposit_test.go new file mode 100644 index 000000000..ddfef6df3 --- /dev/null +++ b/staticaddr/deposit/deposit_test.go @@ -0,0 +1,15 @@ +package deposit + +import "testing" + +// TestIsExpiredUnconfirmed checks that unconfirmed deposits don't start their +// expiry timer. +func TestIsExpiredUnconfirmed(t *testing.T) { + deposit := &Deposit{ + ConfirmationHeight: 0, + } + + if deposit.IsExpired(500, 100) { + t.Fatal("unconfirmed deposit should not be expired") + } +} diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 197bf2ee3..dc54d87d3 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -42,10 +43,26 @@ var ( // States. var ( - // Deposited signals that funds at a static address have reached the - // confirmation height. + // Deposited signals that funds at a static address have been detected + // and are available to the client. Deposited = fsm.StateType("Deposited") + // Replaced signals that a deposit disappeared from the wallet view and + // can no longer be spent. + // + // The concrete case we need to handle is mempool replacement: a user can + // receive to the static address, we persist that unconfirmed outpoint, and + // then the funding transaction can be replaced or otherwise evicted before + // confirmation. Once that happens lnd stops returning the old outpoint from + // ListUnspent, but our DB would otherwise keep presenting it as selectable. + // Replaced lets us retain the historic record while making it clear that the + // original outpoint is no longer a live deposit. + // + // This state is managed directly by the deposit manager rather than via + // DepositStatesV0 because it reflects wallet visibility changes such as + // mempool replacement or deep reorgs, not an FSM-driven spend path. + Replaced = fsm.StateType("Replaced") + // Withdrawing signals that the withdrawal transaction has been // broadcast, awaiting sufficient confirmations. Withdrawing = fsm.StateType("Withdrawing") @@ -93,8 +110,8 @@ var ( // Events. var ( // OnStart is sent to the fsm once the deposit outpoint has been - // sufficiently confirmed. It transitions the fsm into the Deposited - // state from where we can trigger a withdrawal, a loopin or an expiry. + // detected. It transitions the fsm into the Deposited state from where + // we can trigger a withdrawal, a loopin or an expiry. OnStart = fsm.EventType("OnStart") // OnWithdrawInitiated is sent to the fsm when a withdrawal has been @@ -161,12 +178,17 @@ type FSM struct { blockNtfnChan chan uint32 + // stopChan requests shutdown of the block notification loop. + stopChan chan struct{} + // quitChan stops after the FSM stops consuming blockNtfnChan. quitChan chan struct{} // finalizedDepositChan is used to signal that the deposit has been // finalized and the FSM can be removed from the manager's memory. finalizedDepositChan chan wire.OutPoint + + stopOnce sync.Once } // NewFSM creates a new state machine that can action on all static address @@ -192,6 +214,7 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, params: params, address: address, blockNtfnChan: make(chan uint32), + stopChan: make(chan struct{}), quitChan: make(chan struct{}), finalizedDepositChan: finalizedDepositChan, } @@ -227,6 +250,9 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, ctx, currentHeight, ) + case <-fsm.stopChan: + return + case <-ctx.Done(): return } @@ -236,6 +262,17 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, return depoFsm, nil } +// Stop requests shutdown of the FSM's block notification loop. +func (f *FSM) Stop() { + if f == nil || f.stopChan == nil { + return + } + + f.stopOnce.Do(func() { + close(f.stopChan) + }) +} + // handleBlockNotification inspects the current block height and sends the // OnExpiry event to publish the expiry sweep transaction if the deposit timed // out, or it republishes the expiry sweep transaction if it was not yet swept. diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index af8820302..51bbdfd9d 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "sync" + "sync/atomic" "time" "github.com/btcsuite/btcd/txscript" @@ -17,9 +18,8 @@ import ( ) const ( - // MinConfs is the minimum number of confirmations we require for a - // deposit to be considered available for loop-ins, coop-spends and - // timeouts. + // MinConfs is the legacy minimum confirmation target deposits had to + // reach before they were considered ready to be used for swaps. MinConfs = 6 // MaxConfs is unset since we don't require a max number of @@ -33,6 +33,17 @@ const ( // PollInterval is the interval in which we poll for new deposits to our // static address. PollInterval = 10 * time.Second + + // vanishedDepositThreshold is the number of consecutive wallet + // observations in which a Deposited outpoint must be missing before we + // mark it replaced. + // + // A single miss can happen during a transient wallet-view gap while lnd is + // processing a replacement or reorg. Requiring two misses keeps that narrow + // race recoverable without leaving vanished deposits selectable forever. At + // the default PollInterval, this means a vanished deposit can remain active + // for up to roughly 20 seconds. + vanishedDepositThreshold = 2 ) // ManagerConfig holds the configuration for the address manager. @@ -41,6 +52,10 @@ type ManagerConfig struct { // address parameters. AddressManager AddressManager + // ChainKit is used to query the best known chain tip when deriving + // confirmation heights from wallet UTXOs. + ChainKit lndclient.ChainKitClient + // Store is the database store that is used to store static address // related records. Store Store @@ -58,15 +73,28 @@ type ManagerConfig struct { } // Manager manages the address state machines. +// +// Lock order: if both Manager.mu and a Deposit lock are needed, acquire +// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a +// Deposit lock. type Manager struct { cfg *ManagerConfig // mu guards access to the activeDeposits map. mu sync.Mutex + // reconcileMu serializes deposit recovery and reconciliation so restore + // requests can't race the background polling loop, and new deposits are + // discovered and retained exactly once per outpoint. + reconcileMu sync.Mutex + // activeDeposits contains all the active static address outputs. activeDeposits map[wire.OutPoint]*FSM + // missingDeposits counts consecutive wallet observations in which a + // Deposited outpoint was missing from the wallet view. + missingDeposits map[wire.OutPoint]uint8 + // deposits contain all the deposits that have ever been made to the // static address. This field is used to store and recover deposits. It // also serves as a basis for reconciliation of newly detected deposits @@ -77,6 +105,9 @@ type Manager struct { // been finalized. The manager will adjust its internal state and flush // finalized deposits from its memory. finalizedDepositChan chan wire.OutPoint + + // currentHeight stores the currently best known block height. + currentHeight atomic.Uint32 } // NewManager creates a new deposit manager. @@ -84,6 +115,7 @@ func NewManager(cfg *ManagerConfig) *Manager { return &Manager{ cfg: cfg, activeDeposits: make(map[wire.OutPoint]*FSM), + missingDeposits: make(map[wire.OutPoint]uint8), deposits: make(map[wire.OutPoint]*Deposit), finalizedDepositChan: make(chan wire.OutPoint), } @@ -98,6 +130,19 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + var startupHeight uint32 + select { + case height := <-newBlockChan: + startupHeight = uint32(height) + m.currentHeight.Store(startupHeight) + + case err = <-newBlockErrChan: + return err + + case <-ctx.Done(): + return ctx.Err() + } + // Recover previous deposits and static address parameters from the DB. err = m.recoverDeposits(ctx) if err != nil { @@ -108,11 +153,18 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { // Reconcile immediately on startup so deposits are available // before the first ticker fires. - err = m.reconcileDeposits(ctx) + _, err = m.ReconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile deposits: %v", err) } + // The startup height was consumed before recovered deposit FSMs existed. + // Replay it so already-expired recovered deposits can act immediately. + err = m.notifyActiveDeposits(ctx, startupHeight) + if err != nil { + return err + } + // Start the deposit notifier. m.pollDeposits(ctx) @@ -123,32 +175,22 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { for { select { case height := <-newBlockChan: - // Inform all active deposits about a new block arrival. - m.mu.Lock() - activeDeposits := make([]*FSM, 0, len(m.activeDeposits)) - for _, fsm := range m.activeDeposits { - activeDeposits = append(activeDeposits, fsm) - } - m.mu.Unlock() + m.currentHeight.Store(uint32(height)) - for _, fsm := range activeDeposits { - select { - case fsm.blockNtfnChan <- uint32(height): - - case <-fsm.quitChan: - continue + _, err := m.ReconcileDeposits(ctx) + if err != nil { + log.Errorf("unable to reconcile deposits: %v", err) + } - case <-ctx.Done(): - return ctx.Err() - } + err = m.notifyActiveDeposits(ctx, uint32(height)) + if err != nil { + return err } case outpoint := <-m.finalizedDepositChan: // If deposits notify us about their finalization, flush // the finalized deposit from memory. - m.mu.Lock() - delete(m.activeDeposits, outpoint) - m.mu.Unlock() + m.removeActiveDeposit(outpoint) case err = <-newBlockErrChan: return err @@ -159,9 +201,39 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { } } +// notifyActiveDeposits informs all active deposit FSMs about a new block +// height. +func (m *Manager) notifyActiveDeposits(ctx context.Context, + height uint32) error { + + m.mu.Lock() + activeDeposits := make([]*FSM, 0, len(m.activeDeposits)) + for _, fsm := range m.activeDeposits { + activeDeposits = append(activeDeposits, fsm) + } + m.mu.Unlock() + + for _, fsm := range activeDeposits { + select { + case fsm.blockNtfnChan <- height: + + case <-fsm.quitChan: + continue + + case <-ctx.Done(): + return ctx.Err() + } + } + + return nil +} + // recoverDeposits recovers static address parameters, previous deposits and // state machines from the database and starts the deposit notifier. func (m *Manager) recoverDeposits(ctx context.Context) error { + m.reconcileMu.Lock() + defer m.reconcileMu.Unlock() + log.Infof("Recovering static address parameters and deposits...") // Recover deposits. @@ -207,8 +279,10 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { return nil } -// pollDeposits polls new deposits to our static address and notifies the -// manager's event loop about them. +// pollDeposits periodically polls for new deposits to our static address. This +// complements the block-driven reconciliation in the main event loop: while new +// blocks trigger reconcileDeposits to promptly detect confirmations, the ticker +// here catches deposits that appear in the mempool between blocks. func (m *Manager) pollDeposits(ctx context.Context) { log.Debugf("Waiting for new static address deposits...") @@ -218,7 +292,7 @@ func (m *Manager) pollDeposits(ctx context.Context) { for { select { case <-ticker.C: - err := m.reconcileDeposits(ctx) + _, err := m.ReconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile "+ "deposits: %v", err) @@ -235,46 +309,136 @@ func (m *Manager) pollDeposits(ctx context.Context) { // wallet and matches it against the deposits in our memory that we've seen so // far. It picks the newly identified deposits and starts a state machine per // deposit to track its progress. -func (m *Manager) reconcileDeposits(ctx context.Context) error { +func (m *Manager) reconcileDeposits(ctx context.Context) (int, error) { log.Tracef("Reconciling new deposits...") - utxos, err := m.cfg.AddressManager.ListUnspent( - ctx, MinConfs, MaxConfs, - ) + utxos, bestHeight, err := m.listUnspentWithBestHeight(ctx) + if err != nil { + return 0, err + } + + err = m.updateDepositConfirmations(ctx, utxos, bestHeight) + if err != nil { + return 0, fmt.Errorf("unable to update deposit "+ + "confirmations: %w", err) + } + + // If the same outpoint reappeared after a transient wallet-view miss, + // reactivate the existing record before we consider it new or vanished. + err = m.reviveReappearedDeposits(ctx, utxos, bestHeight) if err != nil { - return fmt.Errorf("unable to list new deposits: %w", err) + return 0, fmt.Errorf("unable to revive reappeared deposits: %w", + err) + } + + // After handling reappearances, only still-missing outpoints contribute + // towards replacement detection. + err = m.invalidateVanishedDeposits(ctx, utxos) + if err != nil { + return 0, fmt.Errorf("unable to invalidate vanished "+ + "deposits: %w", err) } newDeposits := m.filterNewDeposits(utxos) if len(newDeposits) == 0 { log.Tracef("No new deposits...") - return nil + return 0, nil } for _, utxo := range newDeposits { - deposit, err := m.createNewDeposit(ctx, utxo) + deposit, err := m.createNewDeposit(ctx, utxo, bestHeight) if err != nil { - return fmt.Errorf("unable to retain new deposit: %w", + return 0, fmt.Errorf("unable to retain new deposit: %w", err) } log.Debugf("Received deposit: %v", deposit) err = m.startDepositFsm(ctx, deposit) if err != nil { - return fmt.Errorf("unable to start new deposit FSM: %w", + return 0, fmt.Errorf("unable to start new deposit FSM: %w", err) } } - return nil + return len(newDeposits), nil +} + +// ReconcileDeposits triggers a best-effort reconciliation pass and returns the +// number of newly discovered deposits. Recovery calls this after restoring the +// address because deposit FSM state is not serialized in backups; it must be +// rebuilt from lnd's current wallet view. +func (m *Manager) ReconcileDeposits(ctx context.Context) (int, error) { + m.reconcileMu.Lock() + defer m.reconcileMu.Unlock() + + return m.reconcileDeposits(ctx) +} + +// listUnspentWithBestHeight returns the wallet's current static-address UTXOs +// together with a stable chain tip height for any confirmed outputs. +func (m *Manager) listUnspentWithBestHeight(ctx context.Context) ( + []*lnwallet.Utxo, int32, error) { + + utxos, err := m.cfg.AddressManager.ListUnspent(ctx, 0, MaxConfs) + if err != nil { + return nil, 0, fmt.Errorf("unable to list new deposits: %w", err) + } + + needsBestHeight := false + for _, utxo := range utxos { + if utxo.Confirmations > 0 { + needsBestHeight = true + break + } + } + + if !needsBestHeight { + return utxos, 0, nil + } + + if m.cfg.ChainKit == nil { + return nil, 0, errors.New("chain kit client required for " + + "confirmed deposits") + } + + const maxAttempts = 3 + for range maxAttempts { + _, beforeHeight, err := m.cfg.ChainKit.GetBestBlock(ctx) + if err != nil { + return nil, 0, fmt.Errorf("unable to get best block "+ + "before listing deposits: %w", err) + } + + utxos, err = m.cfg.AddressManager.ListUnspent(ctx, 0, MaxConfs) + if err != nil { + return nil, 0, fmt.Errorf("unable to list new deposits: %w", + err) + } + + _, afterHeight, err := m.cfg.ChainKit.GetBestBlock(ctx) + if err != nil { + return nil, 0, fmt.Errorf("unable to get best block "+ + "after listing deposits: %w", err) + } + + if beforeHeight == afterHeight { + m.currentHeight.Store(uint32(afterHeight)) + return utxos, afterHeight, nil + } + } + + return nil, 0, errors.New("unable to get stable best block while " + + "listing deposits") } // createNewDeposit transforms the wallet utxo into a deposit struct and stores // it in our database and manager memory. func (m *Manager) createNewDeposit(ctx context.Context, - utxo *lnwallet.Utxo) (*Deposit, error) { + utxo *lnwallet.Utxo, bestHeight int32) (*Deposit, error) { - blockHeight, err := m.getBlockHeight(ctx, utxo) + confirmationHeight, err := confirmationHeightForUtxo( + bestHeight, utxo, + ) if err != nil { return nil, err } @@ -302,7 +466,7 @@ func (m *Manager) createNewDeposit(ctx context.Context, state: Deposited, OutPoint: utxo.OutPoint, Value: utxo.Value, - ConfirmationHeight: int64(blockHeight), + ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, } @@ -318,37 +482,242 @@ func (m *Manager) createNewDeposit(ctx context.Context, return deposit, nil } -// getBlockHeight retrieves the block height of a given utxo. -func (m *Manager) getBlockHeight(ctx context.Context, - utxo *lnwallet.Utxo) (uint32, error) { +// confirmationHeightForUtxo derives the first confirmation height of a wallet +// UTXO from a stable best-known chain tip. Unconfirmed UTXOs return 0. +func confirmationHeightForUtxo(bestHeight int32, + utxo *lnwallet.Utxo) (int64, error) { - addressParams, err := m.cfg.AddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return 0, fmt.Errorf("couldn't get confirmation height for "+ - "deposit, %w", err) + if utxo.Confirmations <= 0 { + return 0, nil + } + + if bestHeight <= 0 { + return 0, fmt.Errorf("invalid best height %d", bestHeight) + } + + firstConfirmationHeight := int64(bestHeight) - utxo.Confirmations + 1 + if firstConfirmationHeight <= 0 { + return 0, fmt.Errorf("invalid confirmation height %d for %v "+ + "with best height %d and %d confirmations", + firstConfirmationHeight, utxo.OutPoint, bestHeight, + utxo.Confirmations) + } + + return firstConfirmationHeight, nil +} + +// updateDepositConfirmations backfills first confirmation heights for deposits +// that were previously detected unconfirmed. +func (m *Manager) updateDepositConfirmations(ctx context.Context, + utxos []*lnwallet.Utxo, bestHeight int32) error { + + for _, utxo := range utxos { + if utxo.Confirmations <= 0 { + continue + } + + m.mu.Lock() + deposit, ok := m.deposits[utxo.OutPoint] + m.mu.Unlock() + if !ok { + continue + } + + deposit.Lock() + if deposit.ConfirmationHeight > 0 { + deposit.Unlock() + continue + } + deposit.Unlock() + + confirmationHeight, err := confirmationHeightForUtxo( + bestHeight, utxo, + ) + if err != nil { + return err + } + + deposit.Lock() + if deposit.ConfirmationHeight > 0 { + deposit.Unlock() + continue + } + + previousConfirmationHeight := deposit.ConfirmationHeight + deposit.ConfirmationHeight = confirmationHeight + + err = m.cfg.Store.UpdateDeposit(ctx, deposit) + if err != nil { + deposit.ConfirmationHeight = previousConfirmationHeight + deposit.Unlock() + return err + } + + deposit.Unlock() + } + + return nil +} + +// reviveReappearedDeposits reactivates deposits that were previously marked as +// replaced if the exact same outpoint reappears in the wallet view. +// +// This is the inverse of invalidateVanishedDeposits: it lets us +// recover from a transient ListUnspent gap without inventing a second record +// for the same outpoint. +func (m *Manager) reviveReappearedDeposits(ctx context.Context, + utxos []*lnwallet.Utxo, bestHeight int32) error { + + type reviveCandidate struct { + deposit *Deposit + utxo *lnwallet.Utxo } - notifChan, errChan, err := - m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, &utxo.OutPoint.Hash, addressParams.PkScript, - MinConfs, addressParams.InitiationHeight, + var candidates []reviveCandidate + + m.mu.Lock() + for _, utxo := range utxos { + delete(m.missingDeposits, utxo.OutPoint) + + deposit, ok := m.deposits[utxo.OutPoint] + if !ok { + continue + } + + if _, active := m.activeDeposits[utxo.OutPoint]; active { + continue + } + + deposit.Lock() + isReplaced := deposit.IsInStateNoLock(Replaced) + deposit.Unlock() + if !isReplaced { + continue + } + + candidates = append(candidates, reviveCandidate{ + deposit: deposit, + utxo: utxo, + }) + } + m.mu.Unlock() + + for _, candidate := range candidates { + confirmationHeight, err := confirmationHeightForUtxo( + bestHeight, candidate.utxo, ) - if err != nil { - return 0, err + if err != nil { + return err + } + + deposit := candidate.deposit + deposit.Lock() + if !deposit.IsInStateNoLock(Replaced) { + deposit.Unlock() + continue + } + + previousState := deposit.state + previousConfirmationHeight := deposit.ConfirmationHeight + deposit.ConfirmationHeight = confirmationHeight + deposit.SetStateNoLock(Deposited) + err = m.cfg.Store.UpdateDeposit(ctx, deposit) + if err != nil { + deposit.ConfirmationHeight = previousConfirmationHeight + deposit.SetStateNoLock(previousState) + deposit.Unlock() + return err + } + + deposit.Unlock() + + log.Infof("Reactivated deposit %v after it reappeared in "+ + "wallet view", deposit.OutPoint) + + err = m.startDepositFsm(ctx, deposit) + if err != nil { + return err + } } - select { - case tx := <-notifChan: - return tx.BlockHeight, nil + return nil +} - case err := <-errChan: - return 0, err +// invalidateVanishedDeposits marks Deposited outputs as replaced once lnd no +// longer reports the outpoint in multiple consecutive wallet observations. +// +// This closes the gap between wallet state and our DB state when a persisted +// deposit later disappears from the wallet view, for example because an +// unconfirmed funding transaction was replaced or because a previously +// confirmed transaction was evicted by a deep reorg. We only invalidate +// deposits that are still in the plain Deposited state. +// +// That keeps the scope narrow: in-flight states like LoopingIn already have +// their own recovery/error handling. +func (m *Manager) invalidateVanishedDeposits(ctx context.Context, + utxos []*lnwallet.Utxo) error { + + currentUtxos := make(map[wire.OutPoint]struct{}, len(utxos)) + for _, utxo := range utxos { + currentUtxos[utxo.OutPoint] = struct{}{} + } - case <-ctx.Done(): - return 0, ctx.Err() + m.mu.Lock() + candidates := make([]*Deposit, 0, len(m.deposits)) + for outpoint, deposit := range m.deposits { + if _, ok := currentUtxos[outpoint]; ok { + delete(m.missingDeposits, outpoint) + continue + } + + deposit.Lock() + isVanishedDeposit := deposit.IsInStateNoLock(Deposited) + deposit.Unlock() + if !isVanishedDeposit { + delete(m.missingDeposits, outpoint) + continue + } + + m.missingDeposits[outpoint]++ + if m.missingDeposits[outpoint] < vanishedDepositThreshold { + log.Debugf("Waiting for another wallet observation before "+ + "marking deposit %v replaced", outpoint) + + continue + } + + delete(m.missingDeposits, outpoint) + candidates = append(candidates, deposit) } + m.mu.Unlock() + + for _, deposit := range candidates { + deposit.Lock() + if !deposit.IsInStateNoLock(Deposited) { + deposit.Unlock() + continue + } + + // Persist the replacement marker before removing the deposit from the + // active set so restarted clients and RPC consumers see the same outcome. + previousState := deposit.state + deposit.SetStateNoLock(Replaced) + err := m.cfg.Store.UpdateDeposit(ctx, deposit) + if err != nil { + deposit.SetStateNoLock(previousState) + deposit.Unlock() + return err + } + + deposit.Unlock() + + m.removeActiveDeposit(deposit.OutPoint) + + log.Infof("Marked vanished deposit %v as replaced", + deposit.OutPoint) + } + + return nil } // filterNewDeposits filters the given utxos for new deposits that we haven't @@ -537,6 +906,19 @@ func unlockDeposits(deposits []*Deposit) { } } +func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) { + m.mu.Lock() + fsm, ok := m.activeDeposits[outpoint] + if ok { + delete(m.activeDeposits, outpoint) + } + m.mu.Unlock() + + if ok { + fsm.Stop() + } +} + // GetAllDeposits returns all active deposits. func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) { return m.cfg.Store.AllDeposits(ctx) diff --git a/staticaddr/deposit/manager_height_test.go b/staticaddr/deposit/manager_height_test.go new file mode 100644 index 000000000..963a737b7 --- /dev/null +++ b/staticaddr/deposit/manager_height_test.go @@ -0,0 +1,40 @@ +package deposit + +import ( + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +// TestConfirmationHeightForUtxo verifies first-confirmation height lookup for +// wallet UTXOs. +func TestConfirmationHeightForUtxo(t *testing.T) { + t.Run("unconfirmed", func(t *testing.T) { + height, err := confirmationHeightForUtxo(0, &lnwallet.Utxo{}) + require.NoError(t, err) + require.Zero(t, height) + }) + + t.Run("confirmed uses best height arithmetic", func(t *testing.T) { + height, err := confirmationHeightForUtxo(101, &lnwallet.Utxo{ + Confirmations: 1, + OutPoint: wire.OutPoint{ + Index: 1, + }, + }) + require.NoError(t, err) + require.EqualValues(t, 101, height) + }) + + t.Run("rejects impossible height", func(t *testing.T) { + _, err := confirmationHeightForUtxo(2, &lnwallet.Utxo{ + Confirmations: 4, + OutPoint: wire.OutPoint{ + Index: 3, + }, + }) + require.ErrorContains(t, err, "invalid confirmation height") + }) +} diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go new file mode 100644 index 000000000..a211d3861 --- /dev/null +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -0,0 +1,440 @@ +package deposit + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func expectStableBestBlock(mockChainKit *MockChainKit, height int32) { + mockChainKit.On( + "GetBestBlock", mock.Anything, + ).Return(chainhash.Hash{}, height, nil).Twice() +} + +func TestReconcileDepositsSerialized(t *testing.T) { + ctx := context.Background() + mockLnd := test.NewMockLnd() + utxo := &lnwallet.Utxo{ + AddressType: lnwallet.TaprootPubkey, + Value: btcutil.Amount(100_000), + Confirmations: 0, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + }, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return((*address.Parameters)(nil), errors.New("fsm init failed")) + + mockStore := new(mockStore) + var createCalls atomic.Int32 + createEntered := make(chan struct{}) + releaseCreate := make(chan struct{}) + mockStore.On( + "CreateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(mock.Arguments) { + if createCalls.Add(1) == 1 { + close(createEntered) + } + + <-releaseCreate + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + + var wg sync.WaitGroup + wg.Add(2) + + errs := make(chan error, 2) + go func() { + defer wg.Done() + _, err := manager.ReconcileDeposits(ctx) + errs <- err + }() + + <-createEntered + + go func() { + defer wg.Done() + _, err := manager.ReconcileDeposits(ctx) + errs <- err + }() + + time.Sleep(100 * time.Millisecond) + close(releaseCreate) + wg.Wait() + close(errs) + + var gotErrs []error + for err := range errs { + gotErrs = append(gotErrs, err) + } + + require.EqualValues(t, 1, createCalls.Load()) + require.Len(t, manager.deposits, 1) + require.Empty(t, manager.activeDeposits) + require.Len(t, gotErrs, 2) + + var errCount int + for _, err := range gotErrs { + if err == nil { + continue + } + + errCount++ + require.ErrorContains(t, err, "unable to start new deposit FSM") + } + require.Equal(t, 1, errCount) +} + +func TestReconcileConfirmedDepositUsesBestBlockHeight(t *testing.T) { + ctx := context.Background() + mockLnd := test.NewMockLnd() + utxo := &lnwallet.Utxo{ + AddressType: lnwallet.TaprootPubkey, + Value: btcutil.Amount(100_000), + Confirmations: 3, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{8}, + Index: 1, + }, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return((*address.Parameters)(nil), errors.New("fsm init failed")) + + mockChainKit := new(MockChainKit) + expectStableBestBlock(mockChainKit, 100) + + mockStore := new(mockStore) + mockStore.On( + "CreateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + createdDeposit := args.Get(1).(*Deposit) + require.EqualValues(t, 98, createdDeposit.ConfirmationHeight) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + ChainKit: mockChainKit, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + + _, err := manager.reconcileDeposits(ctx) + require.ErrorContains(t, err, "unable to start new deposit FSM") +} + +// TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit verifies that a +// single missing ListUnspent observation is reversible, but repeated misses +// still mark the deposit as replaced. +func TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 7, + } + + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(Deposited) + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{}, nil) + + mockStore := new(mockStore) + var updateCalls atomic.Int32 + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updateCalls.Add(1) + updatedDeposit := args.Get(1).(*Deposit) + require.True(t, updatedDeposit.IsInStateNoLock(Replaced)) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + fsm := &FSM{ + stopChan: make(chan struct{}), + quitChan: make(chan struct{}), + } + go func() { + <-fsm.stopChan + close(fsm.quitChan) + }() + manager.activeDeposits[outpoint] = fsm + + // The first miss only increments the consecutive-miss counter. + _, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 0, updateCalls.Load()) + require.Equal(t, Deposited, deposit.GetState()) + require.Len(t, manager.activeDeposits, 1) + + // The second consecutive miss is strong enough evidence to finalize the + // record as replaced. + _, err = manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, updateCalls.Load()) + require.Equal(t, Replaced, deposit.GetState()) + require.Empty(t, manager.activeDeposits) + select { + case <-fsm.quitChan: + + case <-time.After(time.Second): + t.Fatal("fsm did not stop after deposit was replaced") + } +} + +// TestReconcileDepositsInvalidatesVanishedConfirmedDeposit verifies that a +// previously confirmed deposit is also marked replaced if it vanishes from the +// wallet view for multiple consecutive observations. +func TestReconcileDepositsInvalidatesVanishedConfirmedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 4, + } + + deposit := &Deposit{ + OutPoint: outpoint, + ConfirmationHeight: 123, + } + deposit.SetState(Deposited) + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{}, nil) + + mockStore := new(mockStore) + var updateCalls atomic.Int32 + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updateCalls.Add(1) + updatedDeposit := args.Get(1).(*Deposit) + require.True(t, updatedDeposit.IsInStateNoLock(Replaced)) + require.EqualValues( + t, 123, updatedDeposit.ConfirmationHeight, + ) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + fsm := &FSM{ + stopChan: make(chan struct{}), + quitChan: make(chan struct{}), + } + go func() { + <-fsm.stopChan + close(fsm.quitChan) + }() + manager.activeDeposits[outpoint] = fsm + + _, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 0, updateCalls.Load()) + require.Equal(t, Deposited, deposit.GetState()) + require.Len(t, manager.activeDeposits, 1) + + _, err = manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, updateCalls.Load()) + require.Equal(t, Replaced, deposit.GetState()) + require.Empty(t, manager.activeDeposits) + select { + case <-fsm.quitChan: + + case <-time.After(time.Second): + t.Fatal("fsm did not stop after confirmed deposit was replaced") + } +} + +// TestReconcileDepositsReactivatesReappearedReplacedDeposit verifies that the +// same outpoint can be revived if lnd reports it again after being marked +// replaced. +func TestReconcileDepositsReactivatesReappearedReplacedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 5, + } + + deposit := &Deposit{ + OutPoint: outpoint, + Value: btcutil.Amount(100_000), + ConfirmationHeight: 77, + } + deposit.SetState(Replaced) + + utxo := &lnwallet.Utxo{ + OutPoint: outpoint, + Value: deposit.Value, + Confirmations: 0, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return(&address.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, nil) + mockAddressManager.On( + "GetStaticAddress", mock.Anything, + ).Return((*script.StaticAddress)(nil), nil) + + mockStore := new(mockStore) + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updatedDeposit := args.Get(1).(*Deposit) + require.True(t, updatedDeposit.IsInStateNoLock(Deposited)) + require.Zero(t, updatedDeposit.ConfirmationHeight) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + + // Reconciliation should revive the existing record instead of creating a + // second deposit entry for the same outpoint. + _, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.Equal(t, Deposited, deposit.GetState()) + require.Zero(t, deposit.ConfirmationHeight) + require.Len(t, manager.activeDeposits, 1) +} + +// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement +// UTXO is retained as a new deposit while an in-flight deposit remains tied to +// the outpoint selected by a loop-in. +func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { + ctx := context.Background() + mockLnd := test.NewMockLnd() + oldOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 8, + } + newOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{5}, + Index: 9, + } + + depositID, err := GetRandomDepositID() + require.NoError(t, err) + + deposit := &Deposit{ + ID: depositID, + OutPoint: oldOutpoint, + Value: btcutil.Amount(100_000), + } + deposit.SetState(LoopingIn) + + utxo := &lnwallet.Utxo{ + OutPoint: newOutpoint, + Value: deposit.Value, + Confirmations: 0, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return(&address.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, nil) + mockAddressManager.On( + "GetStaticAddress", mock.Anything, + ).Return((*script.StaticAddress)(nil), nil) + + mockStore := new(mockStore) + var createdDeposit *Deposit + mockStore.On( + "CreateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + createdDeposit = args.Get(1).(*Deposit) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + manager.deposits[oldOutpoint] = deposit + fsm := &FSM{} + manager.activeDeposits[oldOutpoint] = fsm + manager.missingDeposits[oldOutpoint] = 1 + + _, err = manager.reconcileDeposits(ctx) + require.NoError(t, err) + + require.Same(t, deposit, manager.deposits[oldOutpoint]) + require.Equal(t, oldOutpoint, deposit.OutPoint) + require.Equal(t, LoopingIn, deposit.GetState()) + + replacement, ok := manager.deposits[newOutpoint] + require.True(t, ok) + require.Same(t, createdDeposit, replacement) + require.NotEqual(t, depositID, replacement.ID) + require.Equal(t, newOutpoint, replacement.OutPoint) + require.Equal(t, Deposited, replacement.GetState()) + require.Zero(t, replacement.ConfirmationHeight) + + require.Same(t, fsm, manager.activeDeposits[oldOutpoint]) + require.NotSame(t, fsm, manager.activeDeposits[newOutpoint]) + require.Empty(t, manager.missingDeposits) + + mockStore.AssertNotCalled( + t, "UpdateDeposit", mock.Anything, mock.Anything, + ) +} diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index ab8aaa7a8..b1116d3b3 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -216,6 +216,44 @@ func (m *MockChainNotifier) RegisterSpendNtfn(ctx context.Context, args.Get(1).(chan error), args.Error(2) } +type MockChainKit struct { + mock.Mock +} + +func (m *MockChainKit) RawClientWithMacAuth( + ctx context.Context) (context.Context, time.Duration, + chainrpc.ChainKitClient) { + + return ctx, 0, nil +} + +func (m *MockChainKit) GetBlock(context.Context, chainhash.Hash) ( + *wire.MsgBlock, error) { + + panic("unexpected GetBlock call") +} + +func (m *MockChainKit) GetBlockHeader(context.Context, chainhash.Hash) ( + *wire.BlockHeader, error) { + + panic("unexpected GetBlockHeader call") +} + +func (m *MockChainKit) GetBestBlock(ctx context.Context) ( + chainhash.Hash, int32, error) { + + args := m.Called(ctx) + + return args.Get(0).(chainhash.Hash), args.Get(1).(int32), + args.Error(2) +} + +func (m *MockChainKit) GetBlockHash(context.Context, int64) ( + chainhash.Hash, error) { + + panic("unexpected GetBlockHash call") +} + // TestManager checks that the manager processes the right channel notifications // while a deposit is expiring. func TestManager(t *testing.T) { @@ -234,6 +272,10 @@ func TestManager(t *testing.T) { runErrChan <- testContext.manager.Run(ctx, initChan) }() + // Send an initial block so the manager can proceed past its startup + // block wait. + testContext.blockChan <- int32(defaultDepositConfirmations) + // Ensure that the manager has been initialized. select { case <-initChan: @@ -304,6 +346,91 @@ func TestManager(t *testing.T) { } } +// TestManagerReplaysStartupBlockToRecoveredDeposits verifies that the initial +// block epoch consumed during startup is delivered to recovered deposit FSMs. +func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + const defaultTimeout = 30 * time.Second + + testContext := newManagerTestContext(t) + + initChan := make(chan struct{}) + runErrChan := make(chan error, 1) + go func() { + runErrChan <- testContext.manager.Run(ctx, initChan) + }() + + // Send only the startup block at the recovered deposit's expiry height. + testContext.blockChan <- int32( + defaultDepositConfirmations + defaultExpiry, + ) + + select { + case <-initChan: + + case err := <-runErrChan: + require.NoError(t, err, "manager failed to start") + + case <-time.After(defaultTimeout): + t.Fatal("manager timed out starting") + } + + select { + case <-testContext.mockLnd.SignOutputRawChannel: + + case <-time.After(defaultTimeout): + t.Fatal("did not receive sign request") + } + + select { + case <-testContext.mockLnd.TxPublishChannel: + + case <-time.After(defaultTimeout): + t.Fatal("did not receive published expiry tx") + } + + cancel() + select { + case err := <-runErrChan: + require.ErrorIs(t, err, context.Canceled) + + case <-time.After(defaultTimeout): + t.Fatal("manager did not stop") + } +} + +func TestRecoverDepositsKeepsSpentWithdrawing(t *testing.T) { + ctx := context.Background() + + id, err := GetRandomDepositID() + require.NoError(t, err) + + storedDeposit := &Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + state: Withdrawing, + Value: btcutil.Amount(100000), + ConfirmationHeight: 42, + } + + testContext := newManagerTestContextWithStoredDeposits( + t, []*Deposit{storedDeposit}, nil, + ) + + err = testContext.manager.recoverDeposits(ctx) + require.NoError(t, err) + + deposits, err := testContext.manager.GetActiveDepositsInState(Withdrawing) + require.NoError(t, err) + require.Len(t, deposits, 1) + require.Equal(t, storedDeposit.OutPoint, deposits[0].OutPoint) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { @@ -320,19 +447,9 @@ type ManagerTestContext struct { // newManagerTestContext creates a new test context for the reservation manager. func newManagerTestContext(t *testing.T) *ManagerTestContext { - mockLnd := test.NewMockLnd() - lndContext := test.NewContext(t, mockLnd) - - mockStaticAddressClient := new(mockStaticAddressClient) - mockAddressManager := new(mockAddressManager) - mockStore := new(mockStore) - mockChainNotifier := new(MockChainNotifier) - confChan := make(chan *chainntnfs.TxConfirmation) - confErrChan := make(chan error) - blockChan := make(chan int32) - blockErrChan := make(chan error) - ID, err := GetRandomDepositID() + require.NoError(t, err) + utxo := &lnwallet.Utxo{ AddressType: lnwallet.TaprootPubkey, Value: btcutil.Amount(100000), @@ -343,7 +460,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Index: 0xffffffff, }, } - require.NoError(t, err) + storedDeposits := []*Deposit{ { ID: ID, @@ -355,6 +472,26 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { }, } + return newManagerTestContextWithStoredDeposits( + t, storedDeposits, []*lnwallet.Utxo{utxo}, + ) +} + +func newManagerTestContextWithStoredDeposits(t *testing.T, + storedDeposits []*Deposit, utxos []*lnwallet.Utxo) *ManagerTestContext { + + mockLnd := test.NewMockLnd() + lndContext := test.NewContext(t, mockLnd) + + mockStaticAddressClient := new(mockStaticAddressClient) + mockAddressManager := new(mockAddressManager) + mockStore := new(mockStore) + mockChainNotifier := new(MockChainNotifier) + confChan := make(chan *chainntnfs.TxConfirmation) + confErrChan := make(chan error) + blockChan := make(chan int32) + blockErrChan := make(chan error) + mockStore.On( "AllDeposits", mock.Anything, ).Return(storedDeposits, nil) @@ -371,7 +508,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, - ).Return([]*lnwallet.Utxo{utxo}, nil) + ).Return(utxos, nil) // Define the expected return values for the mocks. mockChainNotifier.On( diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 70a27811f..7e13dc290 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -12,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" @@ -36,6 +38,8 @@ const ( defaultConfTarget = 3 DefaultPaymentTimeoutSeconds = 60 + + defaultInvoiceCleanupTimeout = 5 * time.Second ) var ( @@ -57,6 +61,24 @@ var ( func (f *FSM) InitHtlcAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { + var event fsm.EventType + invoiceNeedsCleanup := false + defer func() { + // If we created the private invoice but failed before persisting the + // swap, cancel it so retries do not accumulate orphan invoices. + if !invoiceNeedsCleanup || event != fsm.OnError { + return + } + + f.cancelSwapInvoice(ctx) + }() + + returnError := func(err error) fsm.EventType { + event = f.HandleError(err) + + return event + } + // Lock the deposits and transition them to the LoopingIn state. err := f.cfg.DepositManager.TransitionDeposits( ctx, f.loopIn.Deposits, deposit.OnLoopInInitiated, @@ -65,7 +87,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, if err != nil { err = fmt.Errorf("unable to loop-in deposits: %w", err) - return f.HandleError(err) + return returnError(err) } // Calculate the swap invoice amount. The server needs to pay us the @@ -88,7 +110,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, err = fmt.Errorf("unable to create random swap preimage: %w", err) - return f.HandleError(err) + return returnError(err) } f.loopIn.SwapPreimage = swapPreimage f.loopIn.SwapHash = swapPreimage.Hash() @@ -100,7 +122,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, if err != nil { err = fmt.Errorf("unable to derive client htlc key: %w", err) - return f.HandleError(err) + return returnError(err) } f.loopIn.ClientPubkey = keyDesc.PubKey f.loopIn.HtlcKeyLocator = keyDesc.KeyLocator @@ -119,10 +141,14 @@ func (f *FSM) InitHtlcAction(ctx context.Context, if err != nil { err = fmt.Errorf("unable to create swap invoice: %w", err) - return f.HandleError(err) + return returnError(err) } f.loopIn.SwapInvoice = swapInvoice + // From here until CreateLoopIn succeeds, any error path would otherwise + // leave behind a live invoice with no persisted swap to recover it. + invoiceNeedsCleanup = true + f.loopIn.ProtocolVersion = version.AddressProtocolVersion( version.CurrentRPCProtocolVersion(), ) @@ -149,7 +175,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, err = fmt.Errorf("unable to initiate the loop-in with the "+ "server: %w", err) - return f.HandleError(err) + return returnError(err) } // Pushing empty sigs signals the server that we abandoned the swap @@ -171,7 +197,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, pushEmptySigs() err = fmt.Errorf("unable to parse server pubkey: %w", err) - return f.HandleError(err) + return returnError(err) } f.loopIn.ServerPubkey = serverPubkey @@ -185,7 +211,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, err = fmt.Errorf("server response parameters are outside "+ "our allowed range: %w", err) - return f.HandleError(err) + return returnError(err) } f.loopIn.HtlcCltvExpiry = loopInResp.HtlcExpiry @@ -194,7 +220,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, pushEmptySigs() err = fmt.Errorf("unable to convert server nonces: %w", err) - return f.HandleError(err) + return returnError(err) } f.htlcServerNoncesHighFee, err = toNonces( loopInResp.HighFeeHtlcInfo.Nonces, @@ -202,7 +228,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, if err != nil { pushEmptySigs() - return f.HandleError(err) + return returnError(err) } f.htlcServerNoncesExtremelyHighFee, err = toNonces( loopInResp.ExtremeFeeHtlcInfo.Nonces, @@ -210,7 +236,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, if err != nil { pushEmptySigs() - return f.HandleError(err) + return returnError(err) } // We need to defend against the server setting high fees for the htlc @@ -232,7 +258,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, log.Errorf("server htlc tx fee is higher than the configured "+ "allowed maximum: %v > %v", fee, maxHtlcTxFee) - return f.HandleError(ErrFeeTooHigh) + return returnError(ErrFeeTooHigh) } f.loopIn.HtlcTxFeeRate = feeRate @@ -246,7 +272,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, "configured allowed maximum: %v > %v", fee, maxHtlcTxBackupFee) - return f.HandleError(ErrFeeTooHigh) + return returnError(ErrFeeTooHigh) } f.loopIn.HtlcTxHighFeeRate = highFeeRate @@ -262,7 +288,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, "configured allowed maximum: %v > %v", fee, maxHtlcTxBackupFee) - return f.HandleError(ErrFeeTooHigh) + return returnError(ErrFeeTooHigh) } f.loopIn.HtlcTxExtremelyHighFeeRate = extremelyHighFeeRate @@ -276,7 +302,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, err = fmt.Errorf("unable to derive htlc timeout sweep "+ "address: %w", err) - return f.HandleError(err) + return returnError(err) } f.loopIn.HtlcTimeoutSweepAddress = sweepAddress @@ -286,10 +312,148 @@ func (f *FSM) InitHtlcAction(ctx context.Context, pushEmptySigs() err = fmt.Errorf("unable to store loop-in in db: %w", err) - return f.HandleError(err) + return returnError(err) + } + + // Once the swap is stored, restart/recovery code owns invoice lifecycle. + invoiceNeedsCleanup = false + + event = OnHtlcInitiated + + return event +} + +// cancelSwapInvoice best-effort cancels the current swap invoice using a +// detached timeout-limited context so cleanup still runs even if the caller's +// context is already done. +func (f *FSM) cancelSwapInvoice(ctx context.Context) { + cleanupCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), defaultInvoiceCleanupTimeout, + ) + defer cancel() + + err := f.cfg.InvoicesClient.CancelInvoice(cleanupCtx, f.loopIn.SwapHash) + if err != nil { + f.Warnf("unable to cancel invoice for swap %v: %v", + f.loopIn.SwapHash, err) + } +} + +// handleInvoiceUpdate applies the monitor state's invoice-update semantics and +// reports whether the update produced a terminal event. +func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) ( + fsm.EventType, bool) { + + switch update.State { + case invoices.ContractOpen: + return fsm.NoOp, false + + case invoices.ContractAccepted: + return fsm.NoOp, false + + case invoices.ContractSettled: + f.Debugf("received off-chain payment update %v", update.State) + return OnPaymentReceived, true + + case invoices.ContractCanceled: + // If the invoice was canceled we only log here since we still need + // to monitor until the htlc timed out. + log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash) + return fsm.NoOp, false + + default: + err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+ + "canceled", update.State, f.loopIn.SwapHash) + return f.HandleError(err), true + } +} + +// selectedDepositConfirmationHeights returns current confirmation heights for +// the original deposit outpoints selected by this loop-in. +func selectedDepositConfirmationHeights( + loopIn *StaticAddressLoopIn) map[string]int64 { + + confirmations := make(map[string]int64, len(loopIn.Deposits)) + outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints)) + for _, outpoint := range loopIn.DepositOutpoints { + outpoints[outpoint] = struct{}{} + } + + for _, d := range loopIn.Deposits { + if d == nil { + continue + } + + d.Lock() + outpoint := d.OutPoint.String() + confirmationHeight := d.ConfirmationHeight + d.Unlock() + + if _, ok := outpoints[outpoint]; !ok { + continue + } + + confirmations[outpoint] = confirmationHeight + } + + return confirmations +} + +// legacyMinConfsReached returns true once every original deposit is confirmed +// and the youngest original deposit has reached the legacy confirmation target. +func legacyMinConfsReached(outpoints []string, + confirmationHeights map[string]int64, currentHeight int32) bool { + + if currentHeight <= 0 || len(outpoints) == 0 { + return false + } + + youngestConfirmation := int64(0) + for _, outpoint := range outpoints { + confirmationHeight, ok := confirmationHeights[outpoint] + if !ok || confirmationHeight <= 0 { + return false + } + + if confirmationHeight > youngestConfirmation { + youngestConfirmation = confirmationHeight + } + } + + return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1 +} + +// originalDepositOutpointUnavailable checks the original selected deposit +// outpoints against the chain backend's UTXO view. +func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) ( + bool, error) { + + if f.cfg.TxOutChecker == nil { + return false, nil + } + + const includeMempool = true + for _, outpointStr := range f.loopIn.DepositOutpoints { + outpoint, err := wire.NewOutPointFromString(outpointStr) + if err != nil { + return false, fmt.Errorf("invalid deposit outpoint %q: %w", + outpointStr, err) + } + + txOut, err := f.cfg.TxOutChecker.GetTxOut( + ctx, *outpoint, includeMempool, + ) + if err != nil { + return false, fmt.Errorf("unable to get txout %v: %w", + outpoint, err) + } + + if txOut == nil { + return true, nil + } } - return OnHtlcInitiated + return false, nil } // SignHtlcTxAction is called if the htlc was initialized and the server @@ -300,6 +464,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, var err error + outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx) + if err != nil { + return f.HandleError(err) + } + if outpointUnavailable { + err = errors.New("original deposit outpoint no longer available") + f.Warnf("%v, canceling swap invoice", err) + f.cancelSwapInvoice(ctx) + + return f.HandleError(err) + } + f.loopIn.AddressParams, err = f.cfg.AddressManager.GetStaticAddressParameters(ctx) @@ -511,7 +687,28 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) } + var ( + riskAcceptedChan <-chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification + riskRejectedChan <-chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification + cancelRiskNotificationSubscriptions = func() {} + ) + if f.cfg.NotificationManager != nil { + notificationCtx, cancel := context.WithCancel(ctx) + cancelRiskNotificationSubscriptions = cancel + riskAcceptedChan = f.cfg.NotificationManager. + SubscribeStaticLoopInRiskAccepted( + notificationCtx, f.loopIn.SwapHash, + ) + riskRejectedChan = f.cfg.NotificationManager. + SubscribeStaticLoopInRiskRejected( + notificationCtx, f.loopIn.SwapHash, + ) + } + defer cancelRiskNotificationSubscriptions() htlcConfirmed := false + depositsUnlocked := false invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash) if err != nil { @@ -521,30 +718,34 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) } - // Create the swap payment timeout timer. If it runs out we cancel the - // invoice, but keep monitoring the htlc confirmation. - // If the invoice was canceled, e.g. before a restart, we don't need to - // set a new deadline. - var deadlineChan <-chan time.Time - if invoice.State != invoices.ContractCanceled { - // If the invoice is still live we set the timeout to the - // remaining payment time. If too much time has elapsed, e.g. - // after a restart, we set the timeout to 0 to cancel the - // invoice and unlock the deposits immediately. - remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds() - - // If the invoice isn't cancelled yet and the payment timeout - // elapsed, we set the timeout to 0 to cancel the invoice and - // unlock the deposits immediately. Otherwise, we start the - // timer with the remaining seconds to timeout. - timeout := time.Duration(0) * time.Second - if remainingTimeSeconds > 0 { - timeout = time.Duration(remainingTimeSeconds) * - time.Second + // Create the swap payment timeout timer after the server confirms + // confirmation risk was accepted. If a server does not support risk + // notifications, fall back after the legacy deposit confirmation depth. + var ( + deadlineChan <-chan time.Time + deadlineTimer *time.Timer + deadlineStarted bool + ) + defer func() { + if deadlineTimer != nil { + deadlineTimer.Stop() } + }() - deadlineChan = time.NewTimer(timeout).C - } else { + startPaymentDeadline := func(reason string) { + if deadlineStarted || invoice.State == invoices.ContractCanceled { + return + } + + timeout := f.loopIn.PaymentTimeoutDuration() + + f.Infof("starting payment deadline after %s", reason) + deadlineTimer = time.NewTimer(timeout) + deadlineChan = deadlineTimer.C + deadlineStarted = true + } + + if invoice.State == invoices.ContractCanceled { // If the invoice was canceled previously we end our // subscription to invoice updates. cancelInvoiceSubscription() @@ -557,11 +758,9 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // Cancel the lndclient invoice subscription. cancelInvoiceSubscription() - err = f.cfg.InvoicesClient.CancelInvoice(ctx, f.loopIn.SwapHash) - if err != nil { - f.Warnf("unable to cancel invoice "+ - "for swap hash: %v", err) - } + // Reuse the same helper as InitHtlcAction so timeout cleanup follows + // the same detached-context path as early-init cleanup. + f.cancelSwapInvoice(ctx) } for { @@ -603,19 +802,71 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } case <-deadlineChan: + deadlineChan = nil + // If the server didn't pay the invoice on time, we // cancel the invoice and keep monitoring the htlc tx // confirmation. We also need to unlock the deposits to // re-enable them for loop-ins and withdrawals. cancelInvoice() - event := f.UnlockDepositsAction(ctx, nil) - if event != fsm.OnError { + err := f.unlockDeposits(ctx) + if err != nil { f.Errorf("unable to unlock deposits after " + "payment deadline") + continue + } + depositsUnlocked = true + + case riskAccepted, ok := <-riskAcceptedChan: + if !ok { + riskAcceptedChan = nil + continue } + if !bytes.Equal( + riskAccepted.SwapHash, f.loopIn.SwapHash[:], + ) { + + continue + } + + startPaymentDeadline("risk accepted notification") + + case riskRejected, ok := <-riskRejectedChan: + if !ok { + riskRejectedChan = nil + continue + } + + if !bytes.Equal( + riskRejected.SwapHash, f.loopIn.SwapHash[:], + ) { + + continue + } + + cancelInvoiceSubscription() + f.cancelSwapInvoice(ctx) + + return f.HandleError(errors.New( + "server rejected confirmation risk wait", + )) + case currentHeight := <-blockChan: + depositConfirmationHeights := + selectedDepositConfirmationHeights(f.loopIn) + + if legacyMinConfsReached( + f.loopIn.DepositOutpoints, + depositConfirmationHeights, currentHeight, + ) { + + startPaymentDeadline( + "legacy confirmation fallback", + ) + } + // If the htlc is confirmed but blockChan fires before // htlcConfChan, we would wrongfully assume that the // htlc tx was not confirmed which would lead to @@ -641,13 +892,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, if !htlcConfirmed { f.Infof("swap timed out, htlc not confirmed") - // If the htlc hasn't confirmed but the timeout - // path opened up, and we didn't receive the - // swap payment, we consider the swap attempt to - // be failed. We cancelled the invoice, but - // don't need to unlock the deposits because - // that happened when the payment deadline was - // reached. + if !depositsUnlocked { + err = f.unlockDeposits(ctx) + if err != nil { + return f.HandleError(err) + } + } + return OnSwapTimedOut } @@ -671,32 +922,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) - case update := <-invoiceUpdateChan: - switch update.State { - case invoices.ContractOpen: - case invoices.ContractAccepted: - case invoices.ContractSettled: - f.Debugf("received off-chain payment update "+ - "%v", update.State) - - return OnPaymentReceived - - case invoices.ContractCanceled: - // If the invoice was canceled we only log here - // since we still need to monitor until the htlc - // timed out. - log.Warnf("invoice for swap hash %v canceled", - f.loopIn.SwapHash) + case update, ok := <-invoiceUpdateChan: + if !ok { + invoiceUpdateChan = nil + continue + } - default: - err = fmt.Errorf("unexpected invoice state %v "+ - "for swap hash %v canceled", - update.State, f.loopIn.SwapHash) + if event, done := f.handleInvoiceUpdate(update); done { + return event + } - return f.HandleError(err) + case err, ok := <-invoiceErrChan: + if !ok { + invoiceErrChan = nil + continue } - case err = <-invoiceErrChan: f.Errorf("invoice subscription error: %v", err) case <-ctx.Done(): @@ -824,9 +1065,7 @@ func (f *FSM) PaymentReceivedAction(ctx context.Context, func (f *FSM) UnlockDepositsAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { - err := f.cfg.DepositManager.TransitionDeposits( - ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited, - ) + err := f.unlockDeposits(ctx) if err != nil { err = fmt.Errorf("unable to unlock deposits: %w", err) @@ -836,6 +1075,13 @@ func (f *FSM) UnlockDepositsAction(ctx context.Context, return fsm.OnError } +// unlockDeposits resets this loop-in's deposits so they can be selected again. +func (f *FSM) unlockDeposits(ctx context.Context) error { + return f.cfg.DepositManager.TransitionDeposits( + ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited, + ) +} + // createAndPublishHtlcTimeoutSweepTx creates and publishes the htlc timeout // sweep transaction. func (f *FSM) createAndPublishHtlcTimeoutSweepTx(ctx context.Context) error { diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 40983e151..45859cbf4 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -55,10 +55,10 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) { loopIn.SetState(MonitorInvoiceAndHtlcTx) // Seed the mock invoice store so LookupInvoice succeeds. - mockLnd.Invoices[swapHash] = &lndclient.Invoice{ + mockLnd.SetInvoice(&lndclient.Invoice{ Hash: swapHash, State: invoices.ContractOpen, - } + }) cfg := &Config{ AddressManager: &mockAddressManager{ @@ -183,6 +183,9 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { require.Equal(t, OnHtlcInitiated, event) require.Nil(t, f.LastActionError) require.NotNil(t, server.request) + require.EqualValues( + t, swap.StaticAddressKeyFamily, loopIn.HtlcKeyLocator.Family, + ) _, routeHints, _, _, err := swap.DecodeInvoice( mockLnd.ChainParams, server.request.SwapInvoice, @@ -271,60 +274,1108 @@ func testValidateLoopInContract(_ int32, _ int32) error { return nil } -// mockAddressManager is a minimal AddressManager implementation used by the -// test FSM setup. -type mockAddressManager struct { - params *address.Parameters -} +// TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted verifies that the +// payment timeout does not start until the server notifies us that confirmation +// risk was accepted. +func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() -// GetStaticAddressParameters returns the configured address parameters. -func (m *mockAddressManager) GetStaticAddressParameters(_ context.Context) ( - *address.Parameters, error) { + mockLnd := test.NewMockLnd() - return m.params, nil -} + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) -// GetStaticAddress is unused for this test and returns nil. -func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( - *script.StaticAddress, error) { + swapHash := lntypes.Hash{4, 5, 6} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{7}, + Index: 0, + } - return nil, nil -} + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) -// noopDepositManager is a stub DepositManager used to satisfy FSM config. -type noopDepositManager struct{} + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) -// GetAllDeposits implements DepositManager with a no-op. -func (n *noopDepositManager) GetAllDeposits(_ context.Context) ( - []*deposit.Deposit, error) { + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, 1, + ), + } - return nil, nil -} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } -// AllStringOutpointsActiveDeposits implements DepositManager with a no-op. -func (n *noopDepositManager) AllStringOutpointsActiveDeposits( - _ []string, _ fsm.StateType) ([]*deposit.Deposit, bool) { + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) - return nil, false + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before risk acceptance: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + notificationMgr.riskAccepted <- &swapserverrpc.ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled immediately after risk acceptance: %v", + hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } } -// TransitionDeposits implements DepositManager with a no-op. -func (n *noopDepositManager) TransitionDeposits(context.Context, - []*deposit.Deposit, fsm.EventType, fsm.StateType) error { +// TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected verifies that a server-side +// confirmation risk rejection is terminal for the client monitor action. +func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() - return nil + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 6, 7} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskRejected: make( + chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification, 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + notificationMgr.riskRejected <- &swapserverrpc.ServerStaticLoopInRiskRejectedNotification{ // nolint: lll + SwapHash: swapHash[:], + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } } -// DepositsForOutpoints implements DepositManager with a no-op. -func (n *noopDepositManager) DepositsForOutpoints(context.Context, []string, - bool) ([]*deposit.Deposit, error) { +// TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes +// verifies that once the monitor state is reached, a missing original deposit +// outpoint does not cancel the invoice. After HTLC signatures are handed to the +// server, the outpoint can disappear because the server published the expected +// HTLC transaction. +func TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes( + t *testing.T) { - return nil, nil + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 7, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + txOutChecker := &testTxOutChecker{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + TxOutChecker: txOutChecker, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } + + require.Empty(t, txOutChecker.outpoints) + require.Empty(t, txOutChecker.includeMempool) } -// GetActiveDepositsInState implements DepositManager with a no-op. -func (n *noopDepositManager) GetActiveDepositsInState(fsm.StateType) ( - []*deposit.Deposit, error) { +// TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint +// verifies that the outpoint-vanished fallback is only active before payment +// has started. Once the invoice is accepted, the original deposit may disappear +// because the server has moved forward with the swap. +func TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint( + t *testing.T) { - return nil, nil + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{6, 8, 10} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{11}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractAccepted, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + TxOutChecker: &testTxOutChecker{}, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case <-resultChan: + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs verifies that the +// monitor action preserves the legacy payment deadline fallback when no risk +// notification manager is available. +func TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{8}, + Index: 0, + } + depositRecord := &deposit.Deposit{ + OutPoint: depositOutpoint, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before deposit confirmation: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + depositRecord.Lock() + depositRecord.ConfirmationHeight = confirmationHeight + depositRecord.Unlock() + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager +// verifies that old servers that do not send risk notifications still get the +// legacy payment deadline even when the notification manager is configured. +func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 10} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + } + depositRecord := &deposit.Deposit{ + OutPoint: depositOutpoint, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, + 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + depositRecord.Lock() + depositRecord.ConfirmationHeight = confirmationHeight + depositRecord.Unlock() + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before payment deadline: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline verifies that +// deposits are unlocked even if the payment deadline never started before the +// HTLC timeout path opened. +func TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{10, 11, 12} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: mockLnd.Height, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + depositMgr := &recordingDepositManager{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case event := <-resultChan: + require.Equal(t, OnSwapTimedOut, event) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.Equal(t, []fsm.EventType{fsm.OnError}, depositMgr.events) + require.Equal(t, []fsm.StateType{deposit.Deposited}, depositMgr.states) +} + +func waitForMonitorSubscriptions(t *testing.T, ctx context.Context, + mockLnd *test.LndMockServices) { + + t.Helper() + + select { + case <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + + select { + case <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } +} + +// TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a +// present txout does not trigger the RBF cancellation path. +func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) { + originalOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + } + + txOutChecker := &testTxOutChecker{ + txOut: &wire.TxOut{Value: 10_000}, + } + f := &FSM{ + cfg: &Config{ + TxOutChecker: txOutChecker, + }, + loopIn: &StaticAddressLoopIn{ + DepositOutpoints: []string{originalOutpoint.String()}, + }, + } + + unavailable, err := f.originalDepositOutpointUnavailable(t.Context()) + require.NoError(t, err) + require.False(t, unavailable) + require.Equal(t, []wire.OutPoint{originalOutpoint}, txOutChecker.outpoints) + require.Equal(t, []bool{true}, txOutChecker.includeMempool) +} + +// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a +// pending loop-in is canceled before HTLC signing if GetTxOut with mempool +// awareness reports that one of the originally selected outpoints is gone. +func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + swapHash := lntypes.Hash{9, 8, 7} + originalOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + DepositOutpoints: []string{originalOutpoint.String()}, + } + + txOutChecker := &testTxOutChecker{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + TxOutChecker: txOutChecker, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + event := f.SignHtlcTxAction(ctx, nil) + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, f.LastActionError, "original deposit outpoint no longer available", + ) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + require.Equal(t, []wire.OutPoint{originalOutpoint}, txOutChecker.outpoints) + require.Equal(t, []bool{true}, txOutChecker.includeMempool) +} + +// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup +// failures are treated as errors, but do not cancel the invoice. The invoice is +// only canceled when GetTxOut explicitly returns nil for an original outpoint. +func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + swapHash := lntypes.Hash{9, 8, 6} + originalOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + DepositOutpoints: []string{originalOutpoint.String()}, + } + + txOutChecker := &testTxOutChecker{ + err: errors.New("backend unavailable"), + } + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + TxOutChecker: txOutChecker, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + event := f.SignHtlcTxAction(ctx, nil) + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, f.LastActionError, "unable to get txout", + ) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %x", hash) + default: + } +} + +// TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice +// created before a server-side rejection is canceled immediately. +func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{{ + Value: 200_000, + }}, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: DefaultPaymentTimeoutSeconds, + ProtocolVersion: version.ProtocolVersion_V0, + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + DepositManager: &noopDepositManager{}, + WalletKit: mockLnd.WalletKit, + LndClient: mockLnd.Client, + InvoicesClient: mockLnd.LndServices.Invoices, + Server: &initHtlcTestServer{ + loopInErr: errors.New("server rejected swap"), + }, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + // The init step should fail and synchronously trigger deferred invoice + // cleanup. + event := f.InitHtlcAction(ctx, nil) + require.Equal(t, fsm.OnError, event) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, loopIn.SwapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } +} + +// TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure verifies that the early +// fee guard also cancels the pre-created invoice before returning an error. +func TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{{ + Value: 200_000, + }}, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: DefaultPaymentTimeoutSeconds, + ProtocolVersion: version.ProtocolVersion_V0, + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + DepositManager: &noopDepositManager{}, + WalletKit: mockLnd.WalletKit, + LndClient: mockLnd.Client, + InvoicesClient: mockLnd.LndServices.Invoices, + Server: &initHtlcTestServer{ + loopInResp: &swapserverrpc.ServerStaticAddressLoopInResponse{ + HtlcServerPubKey: serverKey.PubKey(). + SerializeCompressed(), + HtlcExpiry: mockLnd.Height + + DefaultLoopInOnChainCltvDelta, + StandardHtlcInfo: &swapserverrpc.ServerHtlcSigningInfo{ + FeeRate: 1_000_000, + }, + HighFeeHtlcInfo: &swapserverrpc.ServerHtlcSigningInfo{}, + ExtremeFeeHtlcInfo: &swapserverrpc. + ServerHtlcSigningInfo{}, + }, + }, + ValidateLoopInContract: func(int32, int32) error { + return nil + }, + MaxStaticAddrHtlcFeePercentage: 0, + MaxStaticAddrHtlcBackupFeePercentage: 1, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + // The fee guard runs before persistence, so the deferred cleanup must + // cancel the invoice on this error path as well. + event := f.InitHtlcAction(ctx, nil) + require.Equal(t, fsm.OnError, event) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, loopIn.SwapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } +} + +// mockAddressManager is a minimal AddressManager implementation used by the +// test FSM setup. +type mockAddressManager struct { + params *address.Parameters +} + +// GetStaticAddressParameters returns the configured address parameters. +func (m *mockAddressManager) GetStaticAddressParameters(_ context.Context) ( + *address.Parameters, error) { + + return m.params, nil +} + +// GetStaticAddress is unused for this test and returns nil. +func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( + *script.StaticAddress, error) { + + return nil, nil +} + +// noopDepositManager is a stub DepositManager used to satisfy FSM config. +type noopDepositManager struct{} + +// GetAllDeposits implements DepositManager with a no-op. +func (n *noopDepositManager) GetAllDeposits(_ context.Context) ( + []*deposit.Deposit, error) { + + return nil, nil +} + +// AllStringOutpointsActiveDeposits implements DepositManager with a no-op. +func (n *noopDepositManager) AllStringOutpointsActiveDeposits( + _ []string, _ fsm.StateType) ([]*deposit.Deposit, bool) { + + return nil, false +} + +// TransitionDeposits implements DepositManager with a no-op. +func (n *noopDepositManager) TransitionDeposits(context.Context, + []*deposit.Deposit, fsm.EventType, fsm.StateType) error { + + return nil +} + +// DepositsForOutpoints implements DepositManager with a no-op. +func (n *noopDepositManager) DepositsForOutpoints(context.Context, []string, + bool) ([]*deposit.Deposit, error) { + + return nil, nil +} + +// GetActiveDepositsInState implements DepositManager with a no-op. +func (n *noopDepositManager) GetActiveDepositsInState(fsm.StateType) ( + []*deposit.Deposit, error) { + + return nil, nil +} + +type recordingDepositManager struct { + noopDepositManager + + events []fsm.EventType + states []fsm.StateType +} + +// TransitionDeposits records transition requests. +func (r *recordingDepositManager) TransitionDeposits(_ context.Context, + _ []*deposit.Deposit, event fsm.EventType, + expectedFinalState fsm.StateType) error { + + r.events = append(r.events, event) + r.states = append(r.states, expectedFinalState) + + return nil +} + +// mockNotificationManager allows tests to push server notifications directly to +// monitor actions. +type mockNotificationManager struct { + riskAccepted chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification + riskRejected chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification +} + +// SubscribeStaticLoopInSweepRequests implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInSweepRequests( + context.Context) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification { + + return make(chan *swapserverrpc.ServerStaticLoopInSweepNotification) +} + +// SubscribeStaticLoopInRiskAccepted implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInRiskAccepted( + context.Context, lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification { + + return m.riskAccepted +} + +// SubscribeStaticLoopInRiskRejected implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInRiskRejected( + context.Context, lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification { + + return m.riskRejected +} + +type testTxOutChecker struct { + txOut *wire.TxOut + err error + + outpoints []wire.OutPoint + includeMempool []bool +} + +func (t *testTxOutChecker) GetTxOut(_ context.Context, + outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) { + + t.outpoints = append(t.outpoints, outpoint) + t.includeMempool = append(t.includeMempool, includeMempool) + + return t.txOut, t.err +} + +// initHtlcTestServer lets InitHtlcAction tests inject a deterministic server +// response without standing up the full gRPC client. +type initHtlcTestServer struct { + swapserverrpc.StaticAddressServerClient + + loopInResp *swapserverrpc.ServerStaticAddressLoopInResponse + loopInErr error +} + +// ServerStaticAddressLoopIn returns the canned response configured by the test. +func (s *initHtlcTestServer) ServerStaticAddressLoopIn(context.Context, + *swapserverrpc.ServerStaticAddressLoopInRequest, ...grpc.CallOption, +) (*swapserverrpc.ServerStaticAddressLoopInResponse, error) { + + return s.loopInResp, s.loopInErr +} + +// PushStaticAddressHtlcSigs accepts the abandonment signal used by error-path +// tests without adding additional assertions. +func (s *initHtlcTestServer) PushStaticAddressHtlcSigs(context.Context, + *swapserverrpc.PushStaticAddressHtlcSigsRequest, ...grpc.CallOption, +) (*swapserverrpc.PushStaticAddressHtlcSigsResponse, error) { + + return &swapserverrpc.PushStaticAddressHtlcSigsResponse{}, nil } diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index 1bf32235a..aa3ee1083 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -4,6 +4,7 @@ import ( "context" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/address" @@ -106,10 +107,34 @@ type QuoteGetter interface { numDeposits uint32, fast bool) (*loop.LoopInQuote, error) } +// TxOutChecker checks whether an outpoint is still available in the chain +// backend's UTXO view. +type TxOutChecker interface { + // GetTxOut returns nil if the outpoint is unavailable or spent. The + // includeMempool flag must be passed through to the underlying chain + // backend. + GetTxOut(ctx context.Context, outpoint wire.OutPoint, + includeMempool bool) (*wire.TxOut, error) +} + type NotificationManager interface { // SubscribeStaticLoopInSweepRequests subscribes to the static loop in // sweep requests. These are sent by the server to the client to request // a sweep of a static loop in that has been finished. SubscribeStaticLoopInSweepRequests(ctx context.Context, ) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification + + // SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk + // accepted notifications. These are sent by the server after the selected + // deposits are accepted by confirmation risk tracking. + SubscribeStaticLoopInRiskAccepted( + ctx context.Context, swapHash lntypes.Hash, + ) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification + + // SubscribeStaticLoopInRiskRejected subscribes to static loop in risk + // rejected notifications. These are sent by the server if it aborts the + // confirmation risk wait before payment. + SubscribeStaticLoopInRiskRejected( + ctx context.Context, swapHash lntypes.Hash, + ) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification } diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 37616c675..df4d5a87e 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -93,8 +93,6 @@ type StaticAddressLoopIn struct { // The outpoints in the format txid:vout that are part of the loop-in // swap. - // TODO(hieblmi): Replace this with a getter method that fetches the - // outpoints from the deposits. DepositOutpoints []string // SelectedAmount is the amount that the user selected for the swap. If @@ -466,12 +464,27 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { // RemainingPaymentTimeSeconds returns the remaining time in seconds until the // payment timeout is reached. The remaining time is calculated from the -// initiation time of the swap. If more than the swaps configured payment +// initiation time of the swap. If more than the swap's configured payment // timeout has passed, the remaining time will be negative. func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 { elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds() - return int64(l.PaymentTimeoutSeconds) - int64(elapsedSinceInitiation) + return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation) +} + +// PaymentTimeoutDuration returns the configured payment timeout duration, +// falling back to the default if the swap predates the persisted timeout field. +func (l *StaticAddressLoopIn) PaymentTimeoutDuration() time.Duration { + return time.Duration(l.paymentTimeoutSeconds()) * time.Second +} + +func (l *StaticAddressLoopIn) paymentTimeoutSeconds() int64 { + timeoutSeconds := int64(l.PaymentTimeoutSeconds) + if timeoutSeconds == 0 { + timeoutSeconds = int64(DefaultPaymentTimeoutSeconds) + } + + return timeoutSeconds } // Outpoints returns the wire outpoints of the deposits. diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 444ab5856..9932e0f15 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math" "slices" "sort" "sync/atomic" @@ -79,6 +80,10 @@ type Config struct { // blocks. ChainNotifier lndclient.ChainNotifierClient + // TxOutChecker checks whether selected deposit outpoints are still + // available before we sign an HTLC transaction for them. + TxOutChecker TxOutChecker + // Signer is the signer client that is used to sign transactions. Signer lndclient.SignerClient @@ -759,8 +764,10 @@ func (m *Manager) initiateLoopIn(ctx context.Context, } swap := &StaticAddressLoopIn{ - SelectedAmount: req.SelectedAmount, - DepositOutpoints: selectedOutpoints, + SelectedAmount: req.SelectedAmount, + DepositOutpoints: append( + []string(nil), selectedOutpoints..., + ), Deposits: selectedDeposits, Label: req.Label, Initiator: req.Initiator, @@ -850,11 +857,11 @@ func (m *Manager) GetAllSwaps(ctx context.Context) ([]*StaticAddressLoopIn, return swaps, nil } -// SelectDeposits sorts the deposits by amount in descending order, then by -// blocks-until-expiry in ascending order. It then selects the deposits that -// are needed to cover the amount requested without leaving a dust change. It -// returns an error if the sum of deposits minus dust is less than the requested -// amount. +// SelectDeposits sorts deposits by confirmation status first, then by amount in +// descending order, then by blocks-until-expiry in ascending order. It then +// selects the deposits that are needed to cover the amount requested without +// leaving a dust change. It returns an error if the sum of deposits minus dust +// is less than the requested amount. func SelectDeposits(targetAmount btcutil.Amount, unfilteredDeposits []*deposit.Deposit, csvExpiry uint32, blockHeight uint32) ([]*deposit.Deposit, error) { @@ -875,14 +882,25 @@ func SelectDeposits(targetAmount btcutil.Amount, deposits = append(deposits, d) } - // Sort the deposits by amount in descending order, then by - // blocks-until-expiry in ascending order. + // Sort confirmed deposits ahead of unconfirmed ones so auto-selection + // prefers deposits the server can accept immediately. Within each group + // we prefer larger deposits, then earlier expiries. 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 { - iExp := uint32(deposits[i].ConfirmationHeight) + - csvExpiry - blockHeight - jExp := uint32(deposits[j].ConfirmationHeight) + - csvExpiry - blockHeight + iExp := blocksUntilDepositExpiry( + uint32(deposits[i].ConfirmationHeight), + blockHeight, csvExpiry, + ) + jExp := blocksUntilDepositExpiry( + uint32(deposits[j].ConfirmationHeight), + blockHeight, csvExpiry, + ) return iExp < jExp } @@ -914,20 +932,33 @@ func SelectDeposits(targetAmount btcutil.Amount, // IsSwappable checks if a deposit is swappable. It returns true if the deposit // is not expired and the htlc is not too close to expiry. func IsSwappable(confirmationHeight, blockHeight, csvExpiry uint32) bool { + if confirmationHeight == 0 { + return true + } + // The deposit expiry height is the confirmation height plus the csv // expiry. - depositExpiryHeight := confirmationHeight + csvExpiry + return blocksUntilDepositExpiry( + confirmationHeight, blockHeight, csvExpiry, + ) >= DefaultLoopInOnChainCltvDelta+DepositHtlcDelta +} - // The htlc expiry height is the current height plus the htlc - // cltv delta. - htlcExpiryHeight := blockHeight + DefaultLoopInOnChainCltvDelta +// blocksUntilDepositExpiry returns the remaining number of blocks until a +// deposit expires. Unconfirmed deposits return MaxUint32 because their CSV has +// not started yet. +func blocksUntilDepositExpiry(confirmationHeight, blockHeight, + csvExpiry uint32) uint32 { - // Ensure that the deposit doesn't expire before the htlc. - if depositExpiryHeight < htlcExpiryHeight+DepositHtlcDelta { - return false + if confirmationHeight == 0 { + return math.MaxUint32 + } + + depositExpiryHeight := confirmationHeight + csvExpiry + if depositExpiryHeight <= blockHeight { + return 0 } - return true + return depositExpiryHeight - blockHeight } // DeduceSwapAmount calculates the swap amount based on the selected amount and diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index d908a9e16..e4a197076 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -71,6 +71,27 @@ func TestSelectDeposits(t *testing.T) { expected: []*deposit.Deposit{d3}, expectedErr: "", }, + { + name: "prefer confirmed deposit over larger unconfirmed one", + deposits: []*deposit.Deposit{ + { + Value: 2_000_000, + ConfirmationHeight: 0, + }, + { + Value: 1_500_000, + ConfirmationHeight: 5_004, + }, + }, + targetValue: 1_000_000, + expected: []*deposit.Deposit{ + { + Value: 1_500_000, + ConfirmationHeight: 5_004, + }, + }, + expectedErr: "", + }, { name: "single deposit insufficient by 1", deposits: []*deposit.Deposit{d1}, @@ -176,6 +197,12 @@ func TestSelectDeposits(t *testing.T) { } } +// TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered +// swappable because its CSV timeout has not started yet. +func TestIsSwappableUnconfirmed(t *testing.T) { + require.True(t, IsSwappable(0, 5000, 1000)) +} + // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { byOutpoint map[string]*deposit.Deposit diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 1b70bbc48..ef8f506aa 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -203,7 +203,7 @@ func (s *SqlStore) GetStaticAddressLoopInSwapsByStates(ctx context.Context, } func toJointStringStates(states []fsm.StateType) string { - return "{" + strings.Join(toStrings(states), ",") + "}" + return strings.Join(toStrings(states), ",") } func toStrings(states []fsm.StateType) []string { @@ -507,9 +507,12 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } } - depositOutpoints := strings.Split( - swap.DepositOutpoints, OutpointSeparator, - ) + var depositOutpoints []string + if swap.DepositOutpoints != "" { + depositOutpoints = strings.Split( + swap.DepositOutpoints, OutpointSeparator, + ) + } timeoutAddressString := swap.HtlcTimeoutSweepAddress var timeoutAddress btcutil.Address diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 356049bc7..dfdce4bf2 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -11,8 +11,10 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" ) @@ -42,7 +44,8 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { loopingDepositID := newID() loopedInDepositID := newID() - d1, d2 := &deposit.Deposit{ + failedDepositID := newID() + d1, d2, d3 := &deposit.Deposit{ ID: loopingDepositID, OutPoint: wire.OutPoint{ Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, @@ -63,29 +66,48 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { TimeOutSweepPkScript: []byte{ 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, }, + }, + &deposit.Deposit{ + ID: failedDepositID, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x3a, 0x2b, 0x3c, 0x4e}, + Index: 2, + }, + Value: btcutil.Amount(300_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4f, + }, } err := depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) err = depositStore.CreateDeposit(ctxb, d2) require.NoError(t, err) + err = depositStore.CreateDeposit(ctxb, d3) + require.NoError(t, err) // Add two updates per deposit, expect the last to be retrieved. d1.SetState(deposit.Deposited) d2.SetState(deposit.Deposited) + d3.SetState(deposit.Deposited) err = depositStore.UpdateDeposit(ctxb, d1) require.NoError(t, err) err = depositStore.UpdateDeposit(ctxb, d2) require.NoError(t, err) + err = depositStore.UpdateDeposit(ctxb, d3) + require.NoError(t, err) d1.SetState(deposit.LoopingIn) d2.SetState(deposit.LoopedIn) + d3.SetState(deposit.Deposited) err = depositStore.UpdateDeposit(ctxb, d1) require.NoError(t, err) err = depositStore.UpdateDeposit(ctxb, d2) require.NoError(t, err) + err = depositStore.UpdateDeposit(ctxb, d3) + require.NoError(t, err) _, clientPubKey := test.CreateKey(1) _, serverPubKey := test.CreateKey(2) @@ -124,6 +146,23 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { err = swapStore.CreateLoopIn(ctxb, &swapSucceeded) require.NoError(t, err) + // Create failed swap. Failed is the last final state, so this + // exercises the state-list query boundary. + swapHashFailed := lntypes.Hash{0x3, 0x2, 0x3, 0x5} + swapFailed := StaticAddressLoopIn{ + SwapHash: swapHashFailed, + SwapPreimage: lntypes.Preimage{0x3, 0x2, 0x3, 0x5}, + DepositOutpoints: []string{d3.OutPoint.String()}, + Deposits: []*deposit.Deposit{d3}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + swapFailed.SetState(Failed) + + err = swapStore.CreateLoopIn(ctxb, &swapFailed) + require.NoError(t, err) + pendingSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates(ctxb, PendingStates) require.NoError(t, err) @@ -142,10 +181,12 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { finalizedSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates(ctxb, FinalStates) require.NoError(t, err) - require.Len(t, finalizedSwaps, 1) + require.Len(t, finalizedSwaps, 2) require.Equal(t, swapHashSucceeded, finalizedSwaps[0].SwapHash) require.Equal(t, []string{d2.OutPoint.String()}, finalizedSwaps[0].DepositOutpoints) require.Equal(t, Succeeded, finalizedSwaps[0].GetState()) + require.Equal(t, swapHashFailed, finalizedSwaps[1].SwapHash) + require.Equal(t, Failed, finalizedSwaps[1].GetState()) finalizedDeposits := finalizedSwaps[0].Deposits require.Len(t, finalizedDeposits, 1) @@ -225,9 +266,13 @@ func TestCreateLoopIn(t *testing.T) { SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4}, DepositOutpoints: []string{d1.OutPoint.String(), d2.OutPoint.String()}, - Deposits: []*deposit.Deposit{d1, d2}, - ClientPubkey: clientPubKey, - ServerPubkey: serverPubKey, + Deposits: []*deposit.Deposit{d1, d2}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcKeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 37, + }, HtlcTimeoutSweepAddress: addr, } swapPending.SetState(SignHtlcTx) @@ -252,22 +297,96 @@ func TestCreateLoopIn(t *testing.T) { require.Contains(t, swapHashes[swapHashPending], depositIDs[0]) require.Contains(t, swapHashes[swapHashPending], depositIDs[1]) - swap, err := swapStore.GetLoopInByHash(ctxb, swapHashPending) + storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHashPending) require.NoError(t, err) - require.Equal(t, swapHashPending, swap.SwapHash) + require.Equal(t, swapHashPending, storedSwap.SwapHash) require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()}, - swap.DepositOutpoints) - require.Equal(t, SignHtlcTx, swap.GetState()) + storedSwap.DepositOutpoints) + require.Equal(t, SignHtlcTx, storedSwap.GetState()) + require.Equal(t, swapPending.HtlcKeyLocator, storedSwap.HtlcKeyLocator) + + require.Len(t, storedSwap.Deposits, 2) - require.Len(t, swap.Deposits, 2) + require.Equal(t, d1.ID, storedSwap.Deposits[0].ID) + require.Equal(t, d1.OutPoint, storedSwap.Deposits[0].OutPoint) + require.Equal(t, d1.Value, storedSwap.Deposits[0].Value) + require.Equal(t, deposit.LoopingIn, storedSwap.Deposits[0].GetState()) - require.Equal(t, d1.ID, swap.Deposits[0].ID) - require.Equal(t, d1.OutPoint, swap.Deposits[0].OutPoint) - require.Equal(t, d1.Value, swap.Deposits[0].Value) - require.Equal(t, deposit.LoopingIn, swap.Deposits[0].GetState()) + require.Equal(t, d2.ID, storedSwap.Deposits[1].ID) + require.Equal(t, d2.OutPoint, storedSwap.Deposits[1].OutPoint) + require.Equal(t, d2.Value, storedSwap.Deposits[1].Value) + require.Equal(t, deposit.LoopingIn, storedSwap.Deposits[1].GetState()) +} - require.Equal(t, d2.ID, swap.Deposits[1].ID) - require.Equal(t, d2.OutPoint, swap.Deposits[1].OutPoint) - require.Equal(t, d2.Value, swap.Deposits[1].Value) - require.Equal(t, deposit.LoopingIn, swap.Deposits[1].GetState()) +// TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins +// keep the original outpoint snapshot stored when the swap was created. +func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) { + ctxb := context.Background() + testDb := loopdb.NewTestDB(t) + testClock := clock.NewTestClock(time.Now()) + defer testDb.Close() + + depositStore := deposit.NewSqlStore(testDb.BaseDB) + swapStore := NewSqlStore( + loopdb.NewTypedStore[Querier](testDb), testClock, + &chaincfg.RegressionNetParams, + ) + + depositID, err := deposit.GetRandomDepositID() + require.NoError(t, err) + + oldOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, + Index: 0, + } + currentOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0x5a, 0x6b, 0x7c, 0x8d}, + Index: 1, + } + + d := &deposit.Deposit{ + ID: depositID, + OutPoint: oldOutpoint, + Value: btcutil.Amount(100_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, + }, + } + require.NoError(t, depositStore.CreateDeposit(ctxb, d)) + + d.SetState(deposit.LoopingIn) + require.NoError(t, depositStore.UpdateDeposit(ctxb, d)) + + _, clientPubKey := test.CreateKey(1) + _, serverPubKey := test.CreateKey(2) + addr, err := btcutil.DecodeAddress(P2wkhAddr, nil) + require.NoError(t, err) + + swapHash := lntypes.Hash{0x1, 0x2, 0x3, 0x4} + swap := StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4}, + DepositOutpoints: []string{oldOutpoint.String()}, + Deposits: []*deposit.Deposit{d}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + swap.SetState(SignHtlcTx) + + require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap)) + + d.OutPoint = currentOutpoint + d.ConfirmationHeight = 42 + require.NoError(t, depositStore.UpdateDeposit(ctxb, d)) + + storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash) + require.NoError(t, err) + require.Equal( + t, []string{oldOutpoint.String()}, + storedSwap.DepositOutpoints, + ) + require.Len(t, storedSwap.Deposits, 1) + require.Equal(t, currentOutpoint, storedSwap.Deposits[0].OutPoint) + require.Equal(t, int64(42), storedSwap.Deposits[0].ConfirmationHeight) } diff --git a/staticaddr/loopin/txout_checker.go b/staticaddr/loopin/txout_checker.go new file mode 100644 index 000000000..61463cc88 --- /dev/null +++ b/staticaddr/loopin/txout_checker.go @@ -0,0 +1,72 @@ +package loopin + +import ( + "context" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" +) + +// lndTxOutChecker checks outpoint availability using lnd's wallet transaction +// view. It returns nil for outputs already spent by a wallet-known transaction. +type lndTxOutChecker struct { + client lndclient.LightningClient +} + +// NewLndTxOutChecker creates a TxOutChecker backed by lnd. +func NewLndTxOutChecker(client lndclient.LightningClient) TxOutChecker { + return &lndTxOutChecker{ + client: client, + } +} + +// GetTxOut returns the tx output if lnd's transaction view still reports the +// outpoint as unspent. +func (c *lndTxOutChecker) GetTxOut(ctx context.Context, + outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) { + + endHeight := int32(0) + if includeMempool { + endHeight = -1 + } + + // We need lnd's wallet transaction view rather than only the funding + // transaction: a matching previous outpoint tells us the deposit has + // already been spent by a wallet-known transaction. When mempool spends + // matter, lnd exposes them through ListTransactions with endHeight=-1. + txs, err := c.client.ListTransactions(ctx, 0, endHeight) + if err != nil { + return nil, err + } + + outpointStr := outpoint.String() + for _, tx := range txs { + for _, prevOutpoint := range tx.PreviousOutpoints { + if prevOutpoint.GetOutpoint() == outpointStr { + return nil, nil + } + } + } + + for _, tx := range txs { + if tx.Tx == nil { + continue + } + + txHash := tx.TxHash + if txHash == "" { + txHash = tx.Tx.TxHash().String() + } + if txHash != outpoint.Hash.String() { + continue + } + + if int(outpoint.Index) >= len(tx.Tx.TxOut) { + return nil, nil + } + + return tx.Tx.TxOut[outpoint.Index], nil + } + + return nil, nil +} diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 2274ce506..d2937506d 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -310,6 +310,10 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, err } + // Automatic channel funding must ignore mempool deposits because + // they cannot yet be used as funding inputs. + deposits = filterConfirmedDeposits(deposits) + if req.LocalFundingAmount != 0 { deposits, err = staticutil.SelectDeposits( deposits, req.LocalFundingAmount, @@ -325,6 +329,14 @@ func (m *Manager) OpenChannel(ctx context.Context, } } + for _, d := range deposits { + // Deposited now includes mempool outputs for static loop-ins, but + // channel opens still require the deposit input to be confirmed. + if d.ConfirmationHeight <= 0 { + return nil, ErrOpeningChannelUnavailableDeposits + } + } + // Pre-check: calculate the channel funding amount and the optional // change before locking deposits. This ensures the selected deposits // can cover the funding amount plus fees. @@ -399,6 +411,22 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, err } +// filterConfirmedDeposits filters the given deposits and returns only those +// that have a positive confirmation height, i.e. deposits that have been +// confirmed on-chain. +func filterConfirmedDeposits(deposits []*deposit.Deposit) []*deposit.Deposit { + confirmed := make([]*deposit.Deposit, 0, len(deposits)) + for _, d := range deposits { + if d.ConfirmationHeight <= 0 { + continue + } + + confirmed = append(confirmed, d) + } + + return confirmed +} + // openChannelPsbt starts an interactive channel open protocol that uses a // partially signed bitcoin transaction (PSBT) to fund the channel output. The // protocol involves several steps between the loop client and the server: diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go index f408da169..e76e6a1b1 100644 --- a/staticaddr/openchannel/manager_test.go +++ b/staticaddr/openchannel/manager_test.go @@ -29,6 +29,7 @@ type transitionCall struct { } type mockDepositManager struct { + activeDeposits []*deposit.Deposit openingDeposits []*deposit.Deposit getErr error transitionErrs map[fsm.EventType]error @@ -44,15 +45,19 @@ func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint, func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) ( []*deposit.Deposit, error) { - if stateFilter != deposit.OpeningChannel { - return nil, nil - } + switch stateFilter { + case deposit.Deposited: + return m.activeDeposits, nil + + case deposit.OpeningChannel: + if m.getErr != nil { + return nil, m.getErr + } - if m.getErr != nil { - return nil, m.getErr + return m.openingDeposits, nil } - return m.openingDeposits, nil + return nil, nil } func (m *mockDepositManager) TransitionDeposits(_ context.Context, @@ -464,6 +469,97 @@ func TestOpenChannelDuplicateOutpoints(t *testing.T) { require.ErrorContains(t, err, "duplicate outpoint") } +// TestOpenChannelSkipsUnconfirmedAutoSelection verifies that automatic coin +// selection ignores mempool deposits and keeps using confirmed ones. +func TestOpenChannelSkipsUnconfirmedAutoSelection(t *testing.T) { + t.Parallel() + + confirmedA := &deposit.Deposit{ + OutPoint: testOutPoint(1), + Value: 160_000, + ConfirmationHeight: 10, + } + confirmedB := &deposit.Deposit{ + OutPoint: testOutPoint(2), + Value: 140_000, + ConfirmationHeight: 11, + } + unconfirmed := &deposit.Deposit{ + OutPoint: testOutPoint(3), + Value: 500_000, + } + + depositManager := &mockDepositManager{ + activeDeposits: []*deposit.Deposit{ + unconfirmed, confirmedA, confirmedB, + }, + transitionErrs: map[fsm.EventType]error{ + deposit.OnOpeningChannel: errors.New("stop after selection"), + }, + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + }, + } + + req := &lnrpc.OpenChannelRequest{ + NodePubkey: make([]byte, 33), + LocalFundingAmount: 100_000, + SatPerVbyte: 10, + } + + _, err := manager.OpenChannel(context.Background(), req) + require.ErrorContains(t, err, "stop after selection") + require.Len(t, depositManager.calls, 1) + require.Equal(t, deposit.OnOpeningChannel, depositManager.calls[0].event) + require.NotContains(t, depositManager.calls[0].outpoints, unconfirmed.OutPoint) +} + +// TestOpenChannelFundMaxSkipsUnconfirmed verifies that fundmax only locks +// confirmed deposits. +func TestOpenChannelFundMaxSkipsUnconfirmed(t *testing.T) { + t.Parallel() + + confirmed := &deposit.Deposit{ + OutPoint: testOutPoint(1), + Value: 200_000, + ConfirmationHeight: 10, + } + unconfirmed := &deposit.Deposit{ + OutPoint: testOutPoint(2), + Value: 300_000, + } + + depositManager := &mockDepositManager{ + activeDeposits: []*deposit.Deposit{ + unconfirmed, confirmed, + }, + transitionErrs: map[fsm.EventType]error{ + deposit.OnOpeningChannel: errors.New("stop after selection"), + }, + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + }, + } + + req := &lnrpc.OpenChannelRequest{ + NodePubkey: make([]byte, 33), + FundMax: true, + SatPerVbyte: 10, + } + + _, err := manager.OpenChannel(context.Background(), req) + require.ErrorContains(t, err, "stop after selection") + require.Len(t, depositManager.calls, 1) + require.Equal( + t, []wire.OutPoint{confirmed.OutPoint}, + depositManager.calls[0].outpoints, + ) +} + // TestValidateInitialPsbtFlags verifies that request fields incompatible with // PSBT funding are rejected early, before any deposits are locked. func TestValidateInitialPsbtFlags(t *testing.T) { diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 99fddd267..f43986881 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -381,6 +381,15 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, } } + for _, d := range deposits { + // Deposited now includes mempool outputs for static loop-ins, but + // withdrawals still require the deposit input to be confirmed. + if d.ConfirmationHeight <= 0 { + return "", "", fmt.Errorf("can't withdraw, " + + "unconfirmed deposits can't be withdrawn") + } + } + var ( withdrawalAddress btcutil.Address err error diff --git a/swap/keychain.go b/swap/keychain.go index 37106950c..eded48133 100644 --- a/swap/keychain.go +++ b/swap/keychain.go @@ -5,7 +5,16 @@ var ( // spending of the htlc. KeyFamily = int32(99) - // StaticAddressKeyFamily is the key family used to generate static - // address keys. + // StaticAddressKeyFamily is the legacy static-address key family. It is + // used for the V0 single static-address key and for static-address HTLC + // keys. StaticAddressKeyFamily = int32(42060) + + // StaticMultiAddressKeyFamily is the key family used to generate + // externally visible multi-address static-address receive keys. + StaticMultiAddressKeyFamily = int32(42061) + + // StaticAddressChangeKeyFamily is the key family used to generate + // static-address change outputs. + StaticAddressChangeKeyFamily = int32(42062) ) diff --git a/swap/keychain_test.go b/swap/keychain_test.go new file mode 100644 index 000000000..99aca8a86 --- /dev/null +++ b/swap/keychain_test.go @@ -0,0 +1,24 @@ +package swap + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStaticAddressKeyFamiliesAreDisjoint documents the key-family split used +// by static-address recovery and multi-address derivation. +func TestStaticAddressKeyFamiliesAreDisjoint(t *testing.T) { + families := map[int32]string{ + KeyFamily: "swap htlc", + StaticAddressKeyFamily: "legacy static address and htlc", + StaticMultiAddressKeyFamily: "multi-address receive", + StaticAddressChangeKeyFamily: "static-address change", + } + + require.Len(t, families, 4) + require.EqualValues(t, 99, KeyFamily) + require.EqualValues(t, 42060, StaticAddressKeyFamily) + require.EqualValues(t, 42061, StaticMultiAddressKeyFamily) + require.EqualValues(t, 42062, StaticAddressChangeKeyFamily) +} diff --git a/test/lightning_client_mock.go b/test/lightning_client_mock.go index fe4198a1d..c59b394ef 100644 --- a/test/lightning_client_mock.go +++ b/test/lightning_client_mock.go @@ -170,7 +170,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context, return nil, fmt.Errorf("invoice: %x not found", hash) } - return inv, nil + invoiceCopy := *inv + + return &invoiceCopy, nil } // ListTransactions returns all known transactions of the backing lnd node. diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index 4fe5d9b5e..a50d8a2a7 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -218,6 +218,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) { s.lock.Unlock() } +// SetInvoice stores a copy of the given invoice in the mock invoice store. +func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) { + s.lock.Lock() + defer s.lock.Unlock() + + invoiceCopy := *invoice + s.Invoices[invoice.Hash] = &invoiceCopy +} + // IsDone checks whether all channels have been fully emptied. If not this may // indicate unexpected behaviour of the code under test. func (s *LndMockServices) IsDone() error { diff --git a/test/signer_mock.go b/test/signer_mock.go index 23ed69533..ba0fa2805 100644 --- a/test/signer_mock.go +++ b/test/signer_mock.go @@ -75,10 +75,23 @@ func (s *mockSigner) VerifyMessage(ctx context.Context, msg, sig []byte, return mockAssertion, nil } -func (s *mockSigner) DeriveSharedKey(context.Context, *btcec.PublicKey, - *keychain.KeyLocator) ([32]byte, error) { +func (s *mockSigner) DeriveSharedKey(_ context.Context, pubKey *btcec.PublicKey, + locator *keychain.KeyLocator) ([32]byte, error) { - return [32]byte{4, 5, 6}, nil + if locator == nil { + return [32]byte{}, fmt.Errorf("missing key locator") + } + if pubKey == nil { + return [32]byte{}, fmt.Errorf("missing pubkey") + } + + privKey, _ := CreateKey(int32(locator.Index)) + sharedSecret := btcec.GenerateSharedSecret(privKey, pubKey) + + var result [32]byte + copy(result[:], sharedSecret) + + return result, nil } // MuSig2CreateSession creates a new MuSig2 signing session using the local diff --git a/test/signer_mock_test.go b/test/signer_mock_test.go new file mode 100644 index 000000000..91685f9a8 --- /dev/null +++ b/test/signer_mock_test.go @@ -0,0 +1,41 @@ +package test + +import ( + "context" + "testing" + + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +func TestMockSignerDeriveSharedKeyDependsOnInputs(t *testing.T) { + t.Parallel() + + signer := NewMockLnd().Signer.(*mockSigner) + _, remotePubKeyA := CreateKey(7) + _, remotePubKeyB := CreateKey(8) + + sharedKeyA0, err := signer.DeriveSharedKey( + context.Background(), remotePubKeyA, &keychain.KeyLocator{ + Index: 0, + }, + ) + require.NoError(t, err) + + sharedKeyA1, err := signer.DeriveSharedKey( + context.Background(), remotePubKeyA, &keychain.KeyLocator{ + Index: 1, + }, + ) + require.NoError(t, err) + + sharedKeyB0, err := signer.DeriveSharedKey( + context.Background(), remotePubKeyB, &keychain.KeyLocator{ + Index: 0, + }, + ) + require.NoError(t, err) + + require.NotEqual(t, sharedKeyA0, sharedKeyA1) + require.NotEqual(t, sharedKeyA0, sharedKeyB0) +} diff --git a/test/walletkit_mock.go b/test/walletkit_mock.go index ee42fa162..b80e9314b 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -9,6 +9,7 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" @@ -33,6 +34,8 @@ type mockWalletKit struct { lnd *LndMockServices keyIndex int32 + importedTaprootScripts map[string]struct{} + feeEstimateLock sync.Mutex feeEstimates map[int32]chainfee.SatPerKWeight minRelayFee chainfee.SatPerKWeight @@ -338,5 +341,21 @@ func (m *mockWalletKit) ImportPublicKey(ctx context.Context, func (m *mockWalletKit) ImportTaprootScript(ctx context.Context, tapscript *waddrmgr.Tapscript) (btcutil.Address, error) { + taprootKey, err := tapscript.TaprootKey() + if err != nil { + return nil, err + } + + if m.importedTaprootScripts == nil { + m.importedTaprootScripts = make(map[string]struct{}) + } + + key := string(schnorr.SerializePubKey(taprootKey)) + if _, ok := m.importedTaprootScripts[key]; ok { + return nil, fmt.Errorf("taproot script already exists") + } + + m.importedTaprootScripts[key] = struct{}{} + return nil, nil }