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..32276c0b6 100644 --- a/cmd/loop/main.go +++ b/cmd/loop/main.go @@ -88,7 +88,9 @@ var ( monitorCommand, quoteCommand, listAuthCommand, fetchL402Command, listSwapsCommand, swapInfoCommand, getLiquidityParamsCommand, setLiquidityRuleCommand, suggestSwapCommand, setParamsCommand, - getInfoCommand, abandonSwapCommand, reservationsCommands, + getInfoCommand, abandonSwapCommand, recoverCommand, + recoverDepositCommand, + reservationsCommands, instantOutCommand, listInstantOutsCommand, stopCommand, printManCommand, printMarkdownCommand, } diff --git a/cmd/loop/recover.go b/cmd/loop/recover.go new file mode 100644 index 000000000..d81e43cf4 --- /dev/null +++ b/cmd/loop/recover.go @@ -0,0 +1,114 @@ +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, +} + +var recoverDepositCommand = &cli.Command{ + Name: "recoverdeposit", + Usage: "recover one static address deposit from on-chain data", + Description: "Verifies the provided transaction output on-chain, " + + "restores the matching static address, stores the deposit, and " + + "starts normal deposit tracking.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "txid", + Usage: "transaction ID containing the deposit output", + Required: true, + }, + &cli.UintFlag{ + Name: "vout", + Usage: "deposit output index", + Required: true, + }, + &cli.IntFlag{ + Name: "height_hint", + Usage: "block height hint for the deposit transaction", + Required: true, + }, + &cli.StringFlag{ + Name: "pkscript_hex", + Usage: "expected static address P2TR pkScript in hex", + Required: true, + }, + &cli.UintFlag{ + Name: "scan_limit", + Usage: "optional highest child index to scan in each " + + "static address key family", + }, + }, + Action: runRecoverDeposit, +} + +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 +} + +func runRecoverDeposit(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.RecoverDeposit( + ctx, &looprpc.RecoverDepositRequest{ + Txid: cmd.String("txid"), + Vout: uint32(cmd.Uint("vout")), + HeightHint: int32(cmd.Int("height_hint")), + PkscriptHex: cmd.String("pkscript_hex"), + ScanLimit: uint32(cmd.Uint("scan_limit")), + }, + ) + if err != nil { + return err + } + + printRespJSON(resp) + return nil +} diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index fc36597e4..12064880f 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -4,16 +4,23 @@ import ( "context" "errors" "fmt" + "math" + "os" + "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" + "golang.org/x/term" ) func init() { @@ -26,6 +33,7 @@ var staticAddressCommands = &cli.Command{ Usage: "perform on-chain to off-chain swaps using static addresses.", Commands: []*cli.Command{ newStaticAddressCommand, + depositStaticAddressCommand, listUnspentCommand, listDepositsCommand, listWithdrawalsCommand, @@ -42,43 +50,307 @@ var newStaticAddressCommand = &cli.Command{ Aliases: []string{"n"}, Usage: "Create a new 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. + Creates a new static loop in address. On a fresh installation loopd + initializes the static-address generation during startup. 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, } +var depositStaticAddressCommand = &cli.Command{ + Name: "deposit", + Usage: "Create and fund a new static loop in address.", + Description: ` + Creates a new static loop in address and initiates a deposit by calling + lnd's SendCoins API with the newly created address as the destination. + `, + Flags: []cli.Flag{ + &cli.Int64Flag{ + Name: "amt", + Usage: "the number of bitcoin denominated in satoshis " + + "to send to the new static address", + }, + &cli.BoolFlag{ + Name: "sweepall", + Usage: "if set, then the amount field should be " + + "unset. This indicates that the wallet will " + + "attempt to sweep all outputs within the " + + "wallet or all funds in selected utxos (when " + + "supplied) to the new static address", + }, + &cli.Int64Flag{ + Name: "conf_target", + Usage: "(optional) the number of blocks that the " + + "funding transaction should confirm in, will " + + "be used for fee estimation", + }, + &cli.Int64Flag{ + Name: "sat_per_byte", + Usage: "Deprecated, use sat_per_vbyte instead.", + Hidden: true, + }, + &cli.Uint64Flag{ + Name: "sat_per_vbyte", + Usage: "(optional) a manual fee expressed in " + + "sat/vbyte that should be used when crafting " + + "the funding transaction", + }, + &cli.Uint64Flag{ + Name: "min_confs", + Usage: "(optional) the minimum number of confirmations " + + "each one of your outputs used for the funding " + + "transaction must satisfy", + Value: defaultUtxoMinConf, + }, + &cli.BoolFlag{ + Name: "force, f", + Usage: "if set, the funding transaction will be " + + "broadcast without asking for confirmation", + }, + staticAddressCoinSelectionStrategyFlag, + &cli.StringSliceFlag{ + Name: "utxo", + Usage: "a utxo specified as outpoint(tx:idx) which " + + "will be used as input for the funding " + + "transaction. This flag can be repeatedly used " + + "to specify multiple utxos as inputs. The " + + "selected utxos can either be entirely spent " + + "by specifying the sweepall flag or a specified " + + "amount can be spent in the utxos through " + + "the amt flag", + }, + staticAddressFundingLabelFlag, + }, + Action: depositStaticAddress, +} + +var ( + staticAddressCoinSelectionStrategyFlag = &cli.StringFlag{ + Name: "coin_selection_strategy", + Usage: "(optional) the strategy to use for selecting coins. " + + "Possible values are 'largest', 'random', or " + + "'global-config'. If either 'largest' or 'random' is " + + "specified, it will override the globally configured " + + "strategy in lnd.conf", + Value: "global-config", + } + + staticAddressFundingLabelFlag = &cli.StringFlag{ + Name: "label", + Usage: "(optional) a label for the funding transaction", + } +) + func newStaticAddress(ctx context.Context, cmd *cli.Command) error { if cmd.NArg() > 0 { return showCommandHelp(ctx, cmd) } - err := displayNewAddressWarning() + client, cleanup, err := getClient(cmd) + if err != nil { + return err + } + defer cleanup() + + err = maybeDisplayNewAddressWarning(ctx, client) + if err != nil { + return err + } + + resp, err := client.NewStaticAddress( + ctx, &looprpc.NewStaticAddressRequest{}, + ) if err != nil { return err } + printRespJSON(resp) + + return nil +} + +func depositStaticAddress(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.NewStaticAddress( + req, err := staticAddressDepositRequest(cmd, "") + if err != nil { + return err + } + + err = maybeDisplayNewAddressWarning(ctx, client) + if err != nil { + return err + } + + addrResp, err := client.NewStaticAddress( ctx, &looprpc.NewStaticAddressRequest{}, ) if err != nil { return err } + req.GetSendCoinsRequest().Addr = addrResp.Address + + if !(cmd.Bool("force") || cmd.Bool("f")) && + term.IsTerminal(int(os.Stdout.Fd())) { + + if !confirmStaticAddressDeposit(req, addrResp.Address) { + return nil + } + } + + resp, err := client.NewStaticAddress(ctx, req) + if err != nil { + return err + } + printRespJSON(resp) return nil } +func staticAddressDepositRequest( + cmd *cli.Command, addr string) (*looprpc.NewStaticAddressRequest, error) { + + if !cmd.IsSet("amt") && !cmd.Bool("sweepall") { + return nil, errors.New("amount argument missing") + } + + amount := cmd.Int64("amt") + if cmd.IsSet("amt") && amount <= 0 { + return nil, errors.New("amount must be positive") + } + + if amount != 0 && cmd.Bool("sweepall") { + return nil, errors.New("amount cannot be set if " + + "attempting to sweep all coins out of the wallet") + } + + feeRateFlag, err := checkNotBothSet( + cmd, "sat_per_vbyte", "sat_per_byte", + ) + if err != nil { + return nil, err + } + + if _, err := checkNotBothSet( + cmd, feeRateFlag, "conf_target", + ); err != nil { + return nil, err + } + + var satPerByte int64 + if cmd.IsSet("sat_per_byte") { + satPerByte = cmd.Int64("sat_per_byte") + if satPerByte < 0 { + return nil, fmt.Errorf("sat_per_byte must be " + + "non-negative") + } + } + + confTarget := cmd.Int64("conf_target") + if confTarget < 0 { + return nil, fmt.Errorf("conf_target must be non-negative") + } + if confTarget > math.MaxInt32 { + return nil, fmt.Errorf("conf_target exceeds maximum " + + "int32 value") + } + + minConfs := cmd.Uint64("min_confs") + if minConfs > math.MaxInt32 { + return nil, fmt.Errorf("min_confs exceeds maximum " + + "int32 value") + } + + var outpoints []*lnrpc.OutPoint + utxos := cmd.StringSlice("utxo") + if len(utxos) > 0 { + outpoints, err = lndcommands.UtxosToOutpoints(utxos) + if err != nil { + return nil, fmt.Errorf("unable to decode utxos: %w", err) + } + } + + coinSelectionStrategy, err := parseStaticAddressCoinSelectionStrategy(cmd) + if err != nil { + return nil, err + } + + return &looprpc.NewStaticAddressRequest{ + SendCoinsRequest: &lnrpc.SendCoinsRequest{ + Addr: addr, + Amount: amount, + TargetConf: int32(confTarget), + SatPerVbyte: cmd.Uint64("sat_per_vbyte"), + SatPerByte: satPerByte, + SendAll: cmd.Bool("sweepall"), + Label: cmd.String( + staticAddressFundingLabelFlag.Name, + ), + MinConfs: int32(minConfs), + SpendUnconfirmed: minConfs == 0, + CoinSelectionStrategy: coinSelectionStrategy, + Outpoints: outpoints, + }, + }, nil +} + +func parseStaticAddressCoinSelectionStrategy(cmd *cli.Command) ( + lnrpc.CoinSelectionStrategy, error) { + + if !cmd.IsSet(staticAddressCoinSelectionStrategyFlag.Name) { + return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG, + nil + } + + switch strategy := cmd.String( + staticAddressCoinSelectionStrategyFlag.Name); strategy { + case "global-config": + return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG, + nil + + case "largest": + return lnrpc.CoinSelectionStrategy_STRATEGY_LARGEST, nil + + case "random": + return lnrpc.CoinSelectionStrategy_STRATEGY_RANDOM, nil + + default: + return 0, fmt.Errorf("unknown coin selection strategy %v", + strategy) + } +} + +func confirmStaticAddressDeposit(req *looprpc.NewStaticAddressRequest, + addr string) bool { + + sendCoinsReq := req.GetSendCoinsRequest() + if sendCoinsReq.GetSendAll() { + fmt.Println("Amount: sweep all eligible wallet funds") + } else { + fmt.Printf("Amount: %d\n", sendCoinsReq.GetAmount()) + } + + fmt.Printf("Destination address: %s\n", addr) + fmt.Printf("Confirm funding transaction (yes/no): ") + + var answer string + fmt.Scanln(&answer) + + return answer == "yes" || answer == "y" +} + var listUnspentCommand = &cli.Command{ Name: "listunspent", Aliases: []string{"l"}, @@ -553,11 +825,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 +889,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 +959,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..26e67a210 --- /dev/null +++ b/cmd/loop/staticaddr_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "context" + "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/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestStaticAddressDepositRequestAllowsNoUtxos(t *testing.T) { + t.Parallel() + + var req *looprpc.NewStaticAddressRequest + cmd := &cli.Command{ + Name: "deposit", + Flags: depositStaticAddressCommand.Flags, + Action: func(_ context.Context, cmd *cli.Command) error { + var err error + req, err = staticAddressDepositRequest( + cmd, "bcrt1ptestaddress", + ) + + return err + }, + } + + err := cmd.Run(context.Background(), []string{ + "deposit", "--amt", "1000000", + }) + require.NoError(t, err) + require.Equal(t, "bcrt1ptestaddress", req.GetSendCoinsRequest().Addr) + require.EqualValues(t, 1_000_000, req.GetSendCoinsRequest().Amount) + require.Empty(t, req.GetSendCoinsRequest().Outpoints) +} + +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, + AddressParams: &address.Parameters{ + Expiry: csvExpiry, + }, + }) + } + + cliSelected := autoSelectedWarningOutpoints( + rpcDeposits, targetAmount, + ) + + loopInSelected, err := loopin.SelectDeposits( + btcutil.Amount(targetAmount), loopInDeposits, 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..95a5f981b 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 @@ -461,6 +471,40 @@ Create a new static loop in address. .PP \fB--help, -h\fP: show help +.SS deposit +.PP +Create and fund a new static loop in address. + +.PP +\fB--amt\fP="": the number of bitcoin denominated in satoshis to send to the new static address (default: 0) + +.PP +\fB--coin_selection_strategy\fP="": (optional) the strategy to use for selecting coins. Possible values are 'largest', 'random', or 'global-config'. If either 'largest' or 'random' is specified, it will override the globally configured strategy in lnd.conf (default: global-config) + +.PP +\fB--conf_target\fP="": (optional) the number of blocks that the funding transaction should confirm in, will be used for fee estimation (default: 0) + +.PP +\fB--force\fP: if set, the funding transaction will be broadcast without asking for confirmation + +.PP +\fB--help, -h\fP: show help + +.PP +\fB--label\fP="": (optional) a label for the funding transaction + +.PP +\fB--min_confs\fP="": (optional) the minimum number of confirmations each one of your outputs used for the funding transaction must satisfy (default: 1) + +.PP +\fB--sat_per_vbyte\fP="": (optional) a manual fee expressed in sat/vbyte that should be used when crafting the funding transaction (default: 0) + +.PP +\fB--sweepall\fP: if set, then the amount field should be unset. This indicates that the wallet will attempt to sweep all outputs within the wallet or all funds in selected utxos (when supplied) to the new static address + +.PP +\fB--utxo\fP="": a utxo specified as outpoint(tx:idx) which will be used as input for the funding transaction. This flag can be repeatedly used to specify multiple utxos as inputs. The selected utxos can either be entirely spent by specifying the sweepall flag or a specified amount can be spent in the utxos through the amt flag (default: []) + .SS listunspent, l .PP List unspent static address outputs. diff --git a/docs/loop.md b/docs/loop.md index 3a847e96c..c8a9623fb 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. @@ -531,7 +550,7 @@ The following flags are supported: Create a new 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. +Creates a new static loop in address. On a fresh installation loopd initializes the static-address generation during startup. 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: @@ -545,6 +564,33 @@ The following flags are supported: |-----------------|-------------|------|:-------------:| | `--help` (`-h`) | show help | bool | `false` | +### `static deposit` subcommand + +Create and fund a new static loop in address. + +Creates a new static loop in address and initiates a deposit by calling lnd's SendCoins API with the newly created address as the destination. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] static deposit [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:---------------:| +| `--amt="…"` | the number of bitcoin denominated in satoshis to send to the new static address | int | `0` | +| `--sweepall` | if set, then the amount field should be unset. This indicates that the wallet will attempt to sweep all outputs within the wallet or all funds in selected utxos (when supplied) to the new static address | bool | `false` | +| `--conf_target="…"` | (optional) the number of blocks that the funding transaction should confirm in, will be used for fee estimation | int | `0` | +| `--sat_per_vbyte="…"` | (optional) a manual fee expressed in sat/vbyte that should be used when crafting the funding transaction | uint | `0` | +| `--min_confs="…"` | (optional) the minimum number of confirmations each one of your outputs used for the funding transaction must satisfy | uint | `1` | +| `--force` | if set, the funding transaction will be broadcast without asking for confirmation | bool | `false` | +| `--coin_selection_strategy="…"` | (optional) the strategy to use for selecting coins. Possible values are 'largest', 'random', or 'global-config'. If either 'largest' or 'random' is specified, it will override the globally configured strategy in lnd.conf | string | `global-config` | +| `--utxo="…"` | a utxo specified as outpoint(tx:idx) which will be used as input for the funding transaction. This flag can be repeatedly used to specify multiple utxos as inputs. The selected utxos can either be entirely spent by specifying the sweepall flag or a specified amount can be spent in the utxos through the amt flag | string | `[]` | +| `--label="…"` | (optional) a label for the funding transaction | string | +| `--help` (`-h`) | show help | bool | `false` | + ### `static listunspent` subcommand (aliases: `l`) List unspent static address outputs. diff --git a/go.mod b/go.mod index 259221452..e50b9d494 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,9 @@ 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 + golang.org/x/term v0.38.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 gopkg.in/macaroon-bakery.v2 v2.3.0 @@ -194,12 +196,10 @@ 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 golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.39.0 // indirect diff --git a/loopd/daemon.go b/loopd/daemon.go index 880e19621..0e99b7c76 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" @@ -555,10 +556,30 @@ func (d *Daemon) initialize(withMacaroonService bool) error { } } + // Static address loop-in store setup is needed by the notification + // manager so confirmation-risk decisions are durable before fan-out. + staticAddressLoopInStore := loopin.NewSqlStore( + loopdb.NewTypedStore[loopin.Querier](baseDb), + clock.NewDefaultClock(), d.lnd.ChainParams, + ) + // Start the notification manager. notificationCfg := ¬ifications.Config{ Client: loop_swaprpc.NewSwapServerClient(swapClient.Conn), CurrentToken: swapClient.L402Store.CurrentToken, + PersistStaticLoopInRiskDecision: func(ctx context.Context, + swapHash lntypes.Hash, accepted bool) error { + + decision := loopin.ConfirmationRiskDecisionRejected + if accepted { + decision = loopin.ConfirmationRiskDecisionAccepted + } + + return staticAddressLoopInStore. + RecordStaticAddressRiskDecision( + ctx, swapHash, decision, + ) + }, } notificationManager := notifications.NewManager(notificationCfg) @@ -577,13 +598,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 +625,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, @@ -641,12 +666,6 @@ func (d *Daemon) initialize(withMacaroonService bool) error { } openChannelManager = openchannel.NewManager(openChannelCfg) - // Static address loop-in manager setup. - staticAddressLoopInStore := loopin.NewSqlStore( - loopdb.NewTypedStore[loopin.Querier](baseDb), - clock.NewDefaultClock(), d.lnd.ChainParams, - ) - // Run the deposit swap hash migration. err = loopin.MigrateDepositSwapHash( d.mainCtx, swapDb, depositStore, staticAddressLoopInStore, @@ -678,6 +697,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 +709,48 @@ 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.EnsureStaticAddressSeed( + 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 +815,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..0cc1e9af1 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" @@ -38,6 +39,8 @@ import ( "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/taproot-assets/rfqmath" + lndlabels "github.com/lightningnetwork/lnd/labels" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/queue" @@ -45,6 +48,7 @@ import ( "github.com/lightningnetwork/lnd/zpay32" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) const ( @@ -101,6 +105,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 @@ -928,24 +933,13 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, "deposits: %w", err) } - // TODO(hieblmi): add params to deposit for multi-address - // support. - params, err := s.staticAddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, fmt.Errorf("unable to retrieve static "+ - "address parameters: %w", err) - } - info, err := s.lnd.Client.GetInfo(ctx) if err != nil { return nil, fmt.Errorf("unable to get lnd info: %w", err) } selectedDeposits, err := loopin.SelectDeposits( - selectedAmount, deposits, params.Expiry, - info.BlockHeight, + selectedAmount, deposits, info.BlockHeight, ) if err != nil { return nil, fmt.Errorf("unable to select deposits: %w", @@ -976,15 +970,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 +1271,70 @@ 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 +} + +// RecoverDeposit verifies and restores one static-address deposit from +// caller-supplied on-chain coordinates. +func (s *swapClientServer) RecoverDeposit(ctx context.Context, + req *looprpc.RecoverDepositRequest) (*looprpc.RecoverDepositResponse, + error) { + + if s.recoveryService == nil { + return nil, status.Error( + codes.Unavailable, "recovery service not configured", + ) + } + + result, err := s.recoveryService.RecoverDeposit( + ctx, &recovery.RecoverDepositRequest{ + TxID: req.GetTxid(), + VOut: req.GetVout(), + HeightHint: req.GetHeightHint(), + PkScriptHex: req.GetPkscriptHex(), + ScanLimit: req.GetScanLimit(), + }, + ) + if err != nil { + return nil, err + } + + return &looprpc.RecoverDepositResponse{ + Outpoint: result.OutPoint, + Value: int64(result.Value), + ConfirmationHeight: result.ConfirmationHeight, + ClientKeyFamily: result.ClientKeyFamily, + ClientKeyIndex: result.ClientKeyIndex, + StaticAddress: result.StaticAddress, + RecoveredAddress: result.RecoveredAddress, + RecoveredDeposit: result.RecoveredDeposit, + DepositId: result.DepositID, + }, nil +} + // GetInfo returns basic information about the loop daemon and details to swaps // from the swap store. func (s *swapClientServer) GetInfo(ctx context.Context, @@ -1629,20 +1696,180 @@ func rpcInstantOut(instantOut *instantout.InstantOut) *looprpc.InstantOut { // NewStaticAddress is the rpc endpoint for loop clients to request a new static // address. func (s *swapClientServer) NewStaticAddress(ctx context.Context, - _ *looprpc.NewStaticAddressRequest) ( + req *looprpc.NewStaticAddressRequest) ( *looprpc.NewStaticAddressResponse, error) { + sendCoinsReq := req.GetSendCoinsRequest() + if err := validateStaticAddressSendCoinsRequest(sendCoinsReq); err != nil { + return nil, err + } + + if sendCoinsReq.GetAddr() != "" { + return s.fundExistingStaticAddress(ctx, sendCoinsReq) + } + staticAddress, expiry, err := s.staticAddressManager.NewAddress(ctx) if err != nil { 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) + } + } + + sendCoinsResp, err := s.sendCoinsToStaticAddress( + ctx, staticAddress.String(), sendCoinsReq, + ) + if err != nil { + return nil, fmt.Errorf("static address %s created, but "+ + "funding transaction failed: %w", staticAddress, err) + } + + return &looprpc.NewStaticAddressResponse{ + Address: staticAddress.String(), + Expiry: uint32(expiry), + SendCoinsResponse: sendCoinsResp, + }, nil +} + +func (s *swapClientServer) fundExistingStaticAddress(ctx context.Context, + req *lnrpc.SendCoinsRequest) (*looprpc.NewStaticAddressResponse, error) { + + staticAddress, expiry, err := s.staticAddressForDeposit(ctx, req.Addr) + if err != nil { + return nil, err + } + + sendCoinsResp, err := s.sendCoinsToStaticAddress( + ctx, staticAddress, req, + ) + if err != nil { + return nil, fmt.Errorf("static address %s funding transaction "+ + "failed: %w", staticAddress, err) + } + return &looprpc.NewStaticAddressResponse{ - Address: staticAddress.String(), - Expiry: uint32(expiry), + Address: staticAddress, + Expiry: expiry, + SendCoinsResponse: sendCoinsResp, }, nil } +func (s *swapClientServer) staticAddressForDeposit(ctx context.Context, + addr string) (string, uint32, error) { + + addresses, err := s.staticAddressManager.GetAllAddresses(ctx) + if err != nil { + return "", 0, err + } + + for _, params := range addresses { + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, + int64(params.Expiry), + ) + if err != nil { + return "", 0, err + } + + if staticAddress.String() == addr { + return addr, params.Expiry, nil + } + } + + return "", 0, status.Errorf(codes.InvalidArgument, + "send_coins_request.addr is not a known static address") +} + +func validateStaticAddressSendCoinsRequest(req *lnrpc.SendCoinsRequest) error { + if req == nil { + return nil + } + + switch { + case req.Amount < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "amount must be non-negative") + + case req.Amount == 0 && !req.SendAll: + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "must set amount or send_all") + + case req.Amount != 0 && req.SendAll: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "amount cannot be set when send_all is true") + + case req.TargetConf < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "target_conf must be non-negative") + + case req.SatPerByte < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "sat_per_byte must be non-negative") + + case req.TargetConf != 0 && + (req.SatPerVbyte != 0 || req.SatPerByte != 0): + + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "can set either target_conf or a fee rate, but not both") + + case req.SatPerVbyte != 0 && req.SatPerByte != 0: + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "can set either sat_per_vbyte or sat_per_byte, but not "+ + "both") + + case req.MinConfs < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "min_confs must be non-negative") + } + + if _, err := lnrpc.ExtractMinConfs( + req.MinConfs, req.SpendUnconfirmed, + ); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "min_confs/spend_unconfirmed invalid: %v", err) + } + + if _, err := lndlabels.ValidateAPI(req.Label); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "label invalid: %v", err) + } + + if _, err := lnrpc.UnmarshallCoinSelectionStrategy( + req.CoinSelectionStrategy, nil, + ); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "coin_selection_strategy invalid: %v", err) + } + + return nil +} + +func (s *swapClientServer) sendCoinsToStaticAddress(ctx context.Context, + addr string, req *lnrpc.SendCoinsRequest) (*lnrpc.SendCoinsResponse, + error) { + + if req == nil { + return nil, nil + } + + sendCoinsReq := proto.Clone(req).(*lnrpc.SendCoinsRequest) + sendCoinsReq.Addr = addr + + rawCtx, timeout, rawClient := s.lnd.Client.RawClientWithMacAuth(ctx) + rawCtx, cancel := context.WithTimeout(rawCtx, timeout) + defer cancel() + + return rawClient.SendCoins(rawCtx, sendCoinsReq) +} + // ListUnspentDeposits returns a list of utxos behind the static address. func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, req *looprpc.ListUnspentDepositsRequest) ( @@ -1650,7 +1877,7 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, // List all unspent utxos the wallet sees, regardless of the number of // confirmations. - staticAddress, utxos, err := s.staticAddressManager.ListUnspentRaw( + utxos, err := s.staticAddressManager.ListUnspentRaw( ctx, req.MinConfs, req.MaxConfs, ) if err != nil { @@ -1658,58 +1885,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. @@ -1719,6 +1923,20 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, continue } + params := s.staticAddressManager.GetParameters(u.PkScript) + if params == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for %v", u.OutPoint) + } + + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, + int64(params.Expiry), + ) + if err != nil { + return nil, err + } + utxo := &looprpc.Utxo{ StaticAddress: staticAddress.String(), AmountSat: int64(u.Value), @@ -1756,8 +1974,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 +1999,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,9 +2042,13 @@ 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, err = s.filterDeposits(allDeposits, f) + if err != nil { + return nil, err } - filteredDeposits = filter(allDeposits, f) if len(outpoints) != len(filteredDeposits) { return nil, fmt.Errorf("not all outpoints found in " + @@ -1814,6 +2056,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. @@ -1822,11 +2068,14 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, return d.IsInState(toServerState(req.StateFilter)) } - filteredDeposits = filter(allDeposits, f) + filteredDeposits, err = s.filterDeposits(allDeposits, f) + if err != nil { + return nil, err + } } // Calculate the blocks until expiry for each deposit. - err = s.populateBlocksUntilExpiry(ctx, filteredDeposits) + err = s.populateBlocksUntilExpiry(ctx, allDeposits, filteredDeposits) if err != nil { infof("Failed to populate blocks until expiry: %v", err) } @@ -1904,13 +2153,6 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, return nil, err } - addrParams, err := s.staticAddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, err - } - // Fetch all deposits at once and index them by swap hash for a quick // lookup. allDeposits, err := s.depositManager.GetAllDeposits(ctx) @@ -1948,9 +2190,16 @@ 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) + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static "+ + "address parameters for deposit %v", + d.OutPoint) + } + blocksUntilExpiry := depositBlocksUntilExpiry( + d.ConfirmationHeight, + d.AddressParams.Expiry, + int64(lndInfo.BlockHeight), + ) pd := &looprpc.Deposit{ Id: d.ID[:], @@ -1999,6 +2248,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, if err != nil { return nil, err } + allDeposits = filterDeposits(allDeposits, isVisibleDeposit) var ( totalNumDeposits = len(allDeposits) @@ -2011,23 +2261,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 @@ -2111,11 +2354,14 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, } // Build a list of used deposits for the response. - usedDeposits := filter( + usedDeposits, err := s.filterDeposits( loopIn.Deposits, func(d *deposit.Deposit) bool { return true }, ) + if err != nil { + return nil, err + } - err = s.populateBlocksUntilExpiry(ctx, usedDeposits) + err = s.populateBlocksUntilExpiry(ctx, loopIn.Deposits, usedDeposits) if err != nil { infof("Failed to populate blocks until expiry: %v", err) } @@ -2157,26 +2403,50 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, // Calculate the blocks until expiry for each deposit and return the modified // StaticAddressLoopInResponse. func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context, - deposits []*looprpc.Deposit) error { + sourceDeposits []*deposit.Deposit, deposits []*looprpc.Deposit) error { lndInfo, err := s.lnd.Client.GetInfo(ctx) if err != nil { return err } - bestBlockHeight := int64(lndInfo.BlockHeight) - params, err := s.staticAddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return err + expiryByOutpoint := make(map[string]uint32, len(sourceDeposits)) + for _, d := range sourceDeposits { + if d.AddressParams == nil { + continue + } + + expiryByOutpoint[d.OutPoint.String()] = d.AddressParams.Expiry } + + bestBlockHeight := int64(lndInfo.BlockHeight) for i := range len(deposits) { - deposits[i].BlocksUntilExpiry = - deposits[i].ConfirmationHeight + - int64(params.Expiry) - bestBlockHeight + expiry, ok := expiryByOutpoint[deposits[i].Outpoint] + if !ok { + continue + } + + deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry( + deposits[i].ConfirmationHeight, 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,35 +2476,87 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context, type filterFunc func(deposits *deposit.Deposit) bool -func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit { +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 (s *swapClientServer) filterDeposits(deposits []*deposit.Deposit, + f filterFunc) ([]*looprpc.Deposit, error) { + var clientDeposits []*looprpc.Deposit for _, d := range deposits { if !f(d) { continue } - swapHash := make([]byte, 0, len(lntypes.Hash{})) - if d.SwapHash != nil { - swapHash = d.SwapHash[:] - } - - hash := d.Hash - outpoint := wire.NewOutPoint(&hash, d.Index).String() - deposit := &looprpc.Deposit{ - Id: d.ID[:], - State: toClientDepositState( - d.GetState(), - ), - Outpoint: outpoint, - Value: int64(d.Value), - ConfirmationHeight: d.ConfirmationHeight, - SwapHash: swapHash, + deposit, err := s.rpcDeposit(d) + if err != nil { + return nil, err } clientDeposits = append(clientDeposits, deposit) } - return clientDeposits + return clientDeposits, nil +} + +func (s *swapClientServer) rpcDeposit(d *deposit.Deposit) ( + *looprpc.Deposit, error) { + + swapHash := make([]byte, 0, len(lntypes.Hash{})) + if d.SwapHash != nil { + swapHash = d.SwapHash[:] + } + + hash := d.Hash + outpoint := wire.NewOutPoint(&hash, d.Index).String() + deposit := &looprpc.Deposit{ + Id: d.ID[:], + State: toClientDepositState( + d.GetState(), + ), + Outpoint: outpoint, + Value: int64(d.Value), + ConfirmationHeight: d.ConfirmationHeight, + SwapHash: swapHash, + } + + if d.AddressParams == nil { + return deposit, nil + } + + if s.staticAddressManager == nil { + return nil, fmt.Errorf("static address manager not configured") + } + + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + d.AddressParams.ClientPubkey, d.AddressParams.ServerPubkey, + int64(d.AddressParams.Expiry), + ) + if err != nil { + return nil, err + } + deposit.StaticAddress = staticAddress.String() + + return deposit, nil } func toClientDepositState(state fsm.StateType) looprpc.DepositState { 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..ee7e751ab --- /dev/null +++ b/loopd/swapclient_server_staticaddr_test.go @@ -0,0 +1,389 @@ +package loopd + +import ( + "context" + "strings" + "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/lightningnetwork/lnd/lnrpc" + "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) UpdateRecoveredDeposit(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 TestValidateStaticAddressSendCoinsRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req *lnrpc.SendCoinsRequest + err string + }{ + { + name: "nil", + }, + { + name: "amount", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + }, + }, + { + name: "send all", + req: &lnrpc.SendCoinsRequest{ + SendAll: true, + }, + }, + { + name: "existing addr", + req: &lnrpc.SendCoinsRequest{ + Addr: "bcrt1ptestaddress", + Amount: 10_000, + }, + }, + { + name: "missing amount", + req: &lnrpc.SendCoinsRequest{}, + err: "must set amount or send_all", + }, + { + name: "negative amount", + req: &lnrpc.SendCoinsRequest{ + Amount: -1, + }, + err: "amount must be non-negative", + }, + { + name: "amount and send all", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + SendAll: true, + }, + err: "amount cannot be set when send_all is true", + }, + { + name: "target and fee rate", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + TargetConf: 6, + SatPerVbyte: 1, + SatPerByte: 0, + SendAll: false, + MinConfs: 1, + Outpoints: nil, + SpendUnconfirmed: false, + }, + err: "can set either target_conf or a fee rate", + }, + { + name: "both fee rates", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + SatPerVbyte: 1, + SatPerByte: 1, + }, + err: "can set either sat_per_vbyte or sat_per_byte", + }, + { + name: "negative min confs", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + MinConfs: -1, + }, + err: "min_confs must be non-negative", + }, + { + name: "min confs with spend unconfirmed", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + MinConfs: 1, + SpendUnconfirmed: true, + }, + err: "spend_unconfirmed invalid", + }, + { + name: "invalid label", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + Label: strings.Repeat("x", 501), + }, + err: "label invalid", + }, + { + name: "invalid coin selection strategy", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + CoinSelectionStrategy: lnrpc.CoinSelectionStrategy(99), + }, + err: "coin_selection_strategy invalid", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateStaticAddressSendCoinsRequest(test.req) + if test.err == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, test.err) + }) + } +} + +func TestStaticAddressForDeposit(t *testing.T) { + t.Parallel() + + ctx := context.Background() + addrMgr, _ := newTestStaticAddressContext(t) + server := &swapClientServer{ + staticAddressManager: addrMgr, + } + + addresses, err := addrMgr.GetAllAddresses(ctx) + require.NoError(t, err) + require.Len(t, addresses, 1) + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + + addr, expiry, err := server.staticAddressForDeposit( + ctx, expectedAddr.String(), + ) + require.NoError(t, err) + require.Equal(t, expectedAddr.String(), addr) + require.Equal(t, addresses[0].Expiry, expiry) + + _, _, err = server.staticAddressForDeposit( + ctx, "bcrt1punknownstaticaddress", + ) + require.ErrorContains(t, err, "not a known static address") +} + +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) + addresses, err := addrMgr.GetAllAddresses(context.Background()) + require.NoError(t, err) + require.Len(t, addresses, 1) + available.AddressParams = addresses[0] + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + + 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, + ) + require.Equal( + t, expectedAddr.String(), + resp.FilteredDeposits[0].StaticAddress, + ) +} + +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..1269d824e 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -1,7 +1,9 @@ package loopd import ( + "bytes" "context" + "database/sql" "os" "testing" "time" @@ -19,8 +21,10 @@ import ( "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" mock_lnd "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwire" @@ -953,10 +957,25 @@ type mockAddressStore struct { func (s *mockAddressStore) CreateStaticAddress(_ context.Context, p *address.Parameters) error { + if p.ID == 0 { + p.ID = int32(len(s.params) + 1) + } s.params = append(s.params, p) return nil } +func (s *mockAddressStore) GetStaticAddressID(_ context.Context, + pkScript []byte) (int32, error) { + + for _, p := range s.params { + if bytes.Equal(p.PkScript, pkScript) { + return p.ID, nil + } + } + + return 0, sql.ErrNoRows +} + func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) ( *address.Parameters, error) { @@ -973,6 +992,16 @@ func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) ( return s.params, nil } +func (s *mockAddressStore) GetLegacyParameters(_ context.Context) ( + *address.Parameters, error) { + + if len(s.params) == 0 { + return nil, sql.ErrNoRows + } + + return s.params[0], nil +} + // mockDepositStore implements deposit.Store minimally for DepositsForOutpoints. type mockDepositStore struct { byOutpoint map[string]*deposit.Deposit @@ -990,6 +1019,12 @@ func (s *mockDepositStore) UpdateDeposit(_ context.Context, return nil } +func (s *mockDepositStore) UpdateRecoveredDeposit(_ context.Context, + _ *deposit.Deposit) error { + + return nil +} + func (s *mockDepositStore) GetDeposit(_ context.Context, _ deposit.ID) (*deposit.Deposit, error) { @@ -1002,7 +1037,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) { @@ -1018,7 +1053,12 @@ func TestListUnspentDeposits(t *testing.T) { // Prepare a single static address parameter set. _, client := mock_lnd.CreateKey(1) _, server := mock_lnd.CreateKey(2) - pkScript := []byte("pkscript") + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 10, client, server, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) addrParams := &address.Parameters{ ClientPubkey: client, ServerPubkey: server, @@ -1026,7 +1066,7 @@ func TestListUnspentDeposits(t *testing.T) { PkScript: pkScript, } - addrStore := &mockAddressStore{params: []*address.Parameters{addrParams}} + addrStore := &mockAddressStore{} // Build an address manager using our mock lnd and fake address store. addrMgr, err := address.NewManager(&address.ManagerConfig{ @@ -1036,6 +1076,8 @@ func TestListUnspentDeposits(t *testing.T) { // ChainNotifier and AddressClient are not needed for this test. }, 1) require.NoError(t, err) + _, _, err = addrMgr.RestoreAddress(ctx, addrParams) + require.NoError(t, err) // Construct several UTXOs with different confirmation counts. makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo { @@ -1051,11 +1093,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 +1115,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 +1140,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 +1149,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 +1178,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 +1206,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/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.down.sql b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.down.sql new file mode 100644 index 000000000..3a7313c18 --- /dev/null +++ b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.down.sql @@ -0,0 +1,3 @@ +-- Drop confirmation-risk decision fields from static address loop-ins. +ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision; +ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision_time; diff --git a/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.up.sql b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.up.sql new file mode 100644 index 000000000..722fc57a3 --- /dev/null +++ b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.up.sql @@ -0,0 +1,9 @@ +-- confirmation_risk_decision records the server's confirmation-risk decision +-- for a static address loop-in. The empty string means no decision has been +-- received yet. +ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision TEXT NOT NULL DEFAULT ''; + +-- confirmation_risk_decision_time records when loopd received and persisted +-- the server's decision, so payment deadlines can be reconstructed after +-- restart. +ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision_time TIMESTAMP; diff --git a/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql b/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql new file mode 100644 index 000000000..e112a7b1f --- /dev/null +++ b/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE deposits DROP COLUMN static_address_id; diff --git a/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql new file mode 100644 index 000000000..4246b116e --- /dev/null +++ b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE deposits ADD static_address_id INT REFERENCES static_addresses(id); + +UPDATE deposits +SET static_address_id = ( + SELECT id FROM static_addresses ORDER BY id ASC LIMIT 1 +) +WHERE static_address_id IS NULL + AND EXISTS (SELECT 1 FROM static_addresses); diff --git a/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql b/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql new file mode 100644 index 000000000..8a0180293 --- /dev/null +++ b/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql @@ -0,0 +1 @@ +ALTER TABLE static_address_swaps DROP COLUMN change_static_address_id; diff --git a/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql b/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql new file mode 100644 index 000000000..6383bf766 --- /dev/null +++ b/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE static_address_swaps + ADD change_static_address_id INT REFERENCES static_addresses(id); + +-- Existing fractional swaps sent change back to the legacy static address. +-- Backfill that relation so in-flight swaps remain recoverable after the +-- client starts requiring explicit per-swap change metadata. +UPDATE static_address_swaps +SET change_static_address_id = ( + SELECT id FROM static_addresses ORDER BY id ASC LIMIT 1 +) +WHERE selected_amount > 0 + AND change_static_address_id IS NULL + AND EXISTS (SELECT 1 FROM static_addresses); diff --git a/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql new file mode 100644 index 000000000..caaf9571a --- /dev/null +++ b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_output_value; + +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_output_index; + +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_tx_id; diff --git a/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql new file mode 100644 index 000000000..1af35c637 --- /dev/null +++ b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE static_address_swaps + ADD confirmed_htlc_tx_id TEXT; + +ALTER TABLE static_address_swaps + ADD confirmed_htlc_output_index INTEGER; + +ALTER TABLE static_address_swaps + ADD confirmed_htlc_output_value BIGINT; diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index 34d8a2778..94e9cf1eb 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -20,6 +20,7 @@ type Deposit struct { ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString SwapHash []byte + StaticAddressID sql.NullInt32 } type DepositUpdate struct { @@ -137,18 +138,24 @@ type StaticAddress struct { } type StaticAddressSwap struct { - ID int32 - SwapHash []byte - SwapInvoice string - LastHop []byte - PaymentTimeoutSeconds int32 - QuotedSwapFeeSatoshis int64 - DepositOutpoints string - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString - HtlcTimeoutSweepAddress string - SelectedAmount int64 - Fast bool + ID int32 + SwapHash []byte + SwapInvoice string + LastHop []byte + PaymentTimeoutSeconds int32 + QuotedSwapFeeSatoshis int64 + DepositOutpoints string + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + HtlcTimeoutSweepAddress string + SelectedAmount int64 + Fast bool + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 } type StaticAddressSwapUpdate struct { diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index 2d8dc1379..f588fc585 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -10,7 +10,7 @@ import ( ) type Querier interface { - AllDeposits(ctx context.Context) ([]Deposit, error) + AllDeposits(ctx context.Context) ([]AllDepositsRow, error) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) CancelBatch(ctx context.Context, id int32) error CreateDeposit(ctx context.Context, arg CreateDepositParams) error @@ -18,19 +18,20 @@ type Querier interface { CreateStaticAddress(ctx context.Context, arg CreateStaticAddressParams) error CreateWithdrawal(ctx context.Context, arg CreateWithdrawalParams) error CreateWithdrawalDeposit(ctx context.Context, arg CreateWithdrawalDepositParams) error - DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) + DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (DepositForOutpointRow, error) DepositIDsForSwapHash(ctx context.Context, swapHash []byte) ([][]byte, error) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]DepositsForSwapHashRow, error) FetchLiquidityParams(ctx context.Context) ([]byte, error) GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) GetBatchSweeps(ctx context.Context, batchID int32) ([]Sweep, error) GetBatchSweptAmount(ctx context.Context, batchID int32) (int64, error) - GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) + GetDeposit(ctx context.Context, depositID []byte) (GetDepositRow, error) GetInstantOutSwap(ctx context.Context, swapHash []byte) (GetInstantOutSwapRow, error) GetInstantOutSwapUpdates(ctx context.Context, swapHash []byte) ([]InstantoutUpdate, error) GetInstantOutSwaps(ctx context.Context) ([]GetInstantOutSwapsRow, error) GetLastUpdateID(ctx context.Context, swapHash []byte) (int32, error) GetLatestDepositUpdate(ctx context.Context, depositID []byte) (DepositUpdate, error) + GetLegacyAddress(ctx context.Context) (StaticAddress, error) GetLoopInSwap(ctx context.Context, swapHash []byte) (GetLoopInSwapRow, error) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([]StaticAddressSwapUpdate, error) GetLoopInSwaps(ctx context.Context) ([]GetLoopInSwapsRow, error) @@ -42,6 +43,7 @@ type Querier interface { GetReservationUpdates(ctx context.Context, reservationID []byte) ([]ReservationUpdate, error) GetReservations(ctx context.Context) ([]Reservation, error) GetStaticAddress(ctx context.Context, pkscript []byte) (StaticAddress, error) + GetStaticAddressID(ctx context.Context, pkscript []byte) (int32, error) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) GetSwapUpdates(ctx context.Context, swapHash []byte) ([]SwapUpdate, error) @@ -67,11 +69,14 @@ type Querier interface { MapDepositToSwap(ctx context.Context, arg MapDepositToSwapParams) error OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSelectedSwapAmountParams) error OverrideSwapCosts(ctx context.Context, arg OverrideSwapCostsParams) error + RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error + SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error) UpdateBatch(ctx context.Context, arg UpdateBatchParams) error UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error UpdateInstantOut(ctx context.Context, arg UpdateInstantOutParams) error UpdateLoopOutAssetOffchainPayments(ctx context.Context, arg UpdateLoopOutAssetOffchainPaymentsParams) error + UpdateRecoveredDeposit(ctx context.Context, arg UpdateRecoveredDepositParams) error UpdateReservation(ctx context.Context, arg UpdateReservationParams) error UpdateStaticAddressLoopIn(ctx context.Context, arg UpdateStaticAddressLoopInParams) error UpdateWithdrawal(ctx context.Context, arg UpdateWithdrawalParams) error diff --git a/loopdb/sqlc/queries/static_address_deposits.sql b/loopdb/sqlc/queries/static_address_deposits.sql index 2987e469e..a58aff9b6 100644 --- a/loopdb/sqlc/queries/static_address_deposits.sql +++ b/loopdb/sqlc/queries/static_address_deposits.sql @@ -7,7 +7,8 @@ INSERT INTO deposits ( confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, - finalized_withdrawal_tx + finalized_withdrawal_tx, + static_address_id ) VALUES ( $1, $2, @@ -16,7 +17,8 @@ INSERT INTO deposits ( $5, $6, $7, - $8 + $8, + $9 ); -- name: UpdateDeposit :exec @@ -30,6 +32,18 @@ SET WHERE deposits.deposit_id = $1; +-- name: UpdateRecoveredDeposit :exec +UPDATE deposits +SET + tx_hash = $2, + out_index = $3, + amount = $4, + confirmation_height = $5, + timeout_sweep_pk_script = $6, + static_address_id = $7 +WHERE + deposits.deposit_id = $1; + -- name: InsertDepositUpdate :exec INSERT INTO deposit_updates ( deposit_id, @@ -43,17 +57,35 @@ INSERT INTO deposit_updates ( -- name: GetDeposit :one SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE deposit_id = $1; -- name: DepositForOutpoint :one SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE tx_hash = $1 AND @@ -61,11 +93,20 @@ AND -- name: AllDeposits :many SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id ORDER BY - id ASC; + d.id ASC; -- name: GetLatestDepositUpdate :one SELECT @@ -76,4 +117,9 @@ WHERE deposit_id = $1 ORDER BY update_timestamp DESC -LIMIT 1; \ No newline at end of file +LIMIT 1; + +-- name: SetAllNullDepositsStaticAddressID :exec +UPDATE deposits +SET static_address_id = $1 +WHERE static_address_id IS NULL; diff --git a/loopdb/sqlc/queries/static_address_loopin.sql b/loopdb/sqlc/queries/static_address_loopin.sql index 7ee326a2d..a15a03a45 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -10,7 +10,8 @@ INSERT INTO static_address_swaps ( htlc_tx_fee_rate_sat_kw, htlc_timeout_sweep_tx_id, htlc_timeout_sweep_address, - fast + fast, + change_static_address_id ) VALUES ( $1, $2, @@ -22,14 +23,26 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ); -- name: UpdateStaticAddressLoopIn :exec UPDATE static_address_swaps SET htlc_tx_fee_rate_sat_kw = $2, - htlc_timeout_sweep_tx_id = $3 + htlc_timeout_sweep_tx_id = $3, + confirmed_htlc_tx_id = $4, + confirmed_htlc_output_index = $5, + confirmed_htlc_output_value = $6 +WHERE + swap_hash = $1; + +-- name: RecordStaticAddressRiskDecision :exec +UPDATE static_address_swaps +SET + confirmation_risk_decision = $2, + confirmation_risk_decision_time = $3 WHERE swap_hash = $1; @@ -48,13 +61,24 @@ INSERT INTO static_address_swap_updates ( SELECT swaps.*, static_address_swaps.*, - htlc_keys.* + htlc_keys.*, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id WHERE swaps.swap_hash = $1; @@ -62,13 +86,24 @@ WHERE SELECT swaps.*, static_address_swaps.*, - htlc_keys.* + htlc_keys.*, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id JOIN static_address_swap_updates u ON swaps.swap_hash = u.swap_hash -- This subquery ensures that we are checking only the latest update for @@ -131,10 +166,19 @@ WHERE -- name: DepositsForSwapHash :many SELECT d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height, u.update_state, u.update_timestamp FROM deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id LEFT JOIN deposit_updates u ON u.id = ( SELECT id @@ -148,6 +192,3 @@ WHERE - - - diff --git a/loopdb/sqlc/queries/static_addresses.sql b/loopdb/sqlc/queries/static_addresses.sql index c613cfd93..cc86fa7e2 100644 --- a/loopdb/sqlc/queries/static_addresses.sql +++ b/loopdb/sqlc/queries/static_addresses.sql @@ -1,10 +1,15 @@ -- name: AllStaticAddresses :many -SELECT * FROM static_addresses; +SELECT * FROM static_addresses +ORDER BY id ASC; -- name: GetStaticAddress :one SELECT * FROM static_addresses WHERE pkscript=$1; +-- name: GetStaticAddressID :one +SELECT id FROM static_addresses +WHERE pkscript=$1; + -- name: CreateStaticAddress :exec INSERT INTO static_addresses ( client_pubkey, @@ -24,4 +29,9 @@ INSERT INTO static_addresses ( $6, $7, $8 - ); \ No newline at end of file + ); + +-- name: GetLegacyAddress :one +SELECT * FROM static_addresses +ORDER BY id ASC +LIMIT 1; diff --git a/loopdb/sqlc/static_address_deposits.sql.go b/loopdb/sqlc/static_address_deposits.sql.go index 191f1f563..1615634ef 100644 --- a/loopdb/sqlc/static_address_deposits.sql.go +++ b/loopdb/sqlc/static_address_deposits.sql.go @@ -13,22 +13,53 @@ import ( const allDeposits = `-- name: AllDeposits :many SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id ORDER BY - id ASC + d.id ASC ` -func (q *Queries) AllDeposits(ctx context.Context) ([]Deposit, error) { +type AllDepositsRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) AllDeposits(ctx context.Context) ([]AllDepositsRow, error) { rows, err := q.db.QueryContext(ctx, allDeposits) if err != nil { return nil, err } defer rows.Close() - var items []Deposit + var items []AllDepositsRow for rows.Next() { - var i Deposit + var i AllDepositsRow if err := rows.Scan( &i.ID, &i.DepositID, @@ -40,6 +71,15 @@ func (q *Queries) AllDeposits(ctx context.Context) ([]Deposit, error) { &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ); err != nil { return nil, err } @@ -63,7 +103,8 @@ INSERT INTO deposits ( confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, - finalized_withdrawal_tx + finalized_withdrawal_tx, + static_address_id ) VALUES ( $1, $2, @@ -72,7 +113,8 @@ INSERT INTO deposits ( $5, $6, $7, - $8 + $8, + $9 ) ` @@ -85,6 +127,7 @@ type CreateDepositParams struct { TimeoutSweepPkScript []byte ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString + StaticAddressID sql.NullInt32 } func (q *Queries) CreateDeposit(ctx context.Context, arg CreateDepositParams) error { @@ -97,15 +140,25 @@ func (q *Queries) CreateDeposit(ctx context.Context, arg CreateDepositParams) er arg.TimeoutSweepPkScript, arg.ExpirySweepTxid, arg.FinalizedWithdrawalTx, + arg.StaticAddressID, ) return err } const depositForOutpoint = `-- name: DepositForOutpoint :one SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE tx_hash = $1 AND @@ -117,9 +170,31 @@ type DepositForOutpointParams struct { OutIndex int32 } -func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) { +type DepositForOutpointRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (DepositForOutpointRow, error) { row := q.db.QueryRowContext(ctx, depositForOutpoint, arg.TxHash, arg.OutIndex) - var i Deposit + var i DepositForOutpointRow err := row.Scan( &i.ID, &i.DepositID, @@ -131,22 +206,62 @@ func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpoint &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ) return i, err } const getDeposit = `-- name: GetDeposit :one SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE deposit_id = $1 ` -func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) { +type GetDepositRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (GetDepositRow, error) { row := q.db.QueryRowContext(ctx, getDeposit, depositID) - var i Deposit + var i GetDepositRow err := row.Scan( &i.ID, &i.DepositID, @@ -158,6 +273,15 @@ func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (Deposit, er &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ) return i, err } @@ -209,6 +333,17 @@ func (q *Queries) InsertDepositUpdate(ctx context.Context, arg InsertDepositUpda return err } +const setAllNullDepositsStaticAddressID = `-- name: SetAllNullDepositsStaticAddressID :exec +UPDATE deposits +SET static_address_id = $1 +WHERE static_address_id IS NULL +` + +func (q *Queries) SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error { + _, err := q.db.ExecContext(ctx, setAllNullDepositsStaticAddressID, staticAddressID) + return err +} + const updateDeposit = `-- name: UpdateDeposit :exec UPDATE deposits SET @@ -241,3 +376,39 @@ func (q *Queries) UpdateDeposit(ctx context.Context, arg UpdateDepositParams) er ) return err } + +const updateRecoveredDeposit = `-- name: UpdateRecoveredDeposit :exec +UPDATE deposits +SET + tx_hash = $2, + out_index = $3, + amount = $4, + confirmation_height = $5, + timeout_sweep_pk_script = $6, + static_address_id = $7 +WHERE + deposits.deposit_id = $1 +` + +type UpdateRecoveredDepositParams struct { + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + StaticAddressID sql.NullInt32 +} + +func (q *Queries) UpdateRecoveredDeposit(ctx context.Context, arg UpdateRecoveredDepositParams) error { + _, err := q.db.ExecContext(ctx, updateRecoveredDeposit, + arg.DepositID, + arg.TxHash, + arg.OutIndex, + arg.Amount, + arg.ConfirmationHeight, + arg.TimeoutSweepPkScript, + arg.StaticAddressID, + ) + return err +} diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 87949cb3e..7eb744158 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -45,11 +45,20 @@ func (q *Queries) DepositIDsForSwapHash(ctx context.Context, swapHash []byte) ([ const depositsForSwapHash = `-- name: DepositsForSwapHash :many SELECT - d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height, u.update_state, u.update_timestamp FROM deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id LEFT JOIN deposit_updates u ON u.id = ( SELECT id @@ -73,6 +82,15 @@ type DepositsForSwapHashRow struct { ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 UpdateState sql.NullString UpdateTimestamp sql.NullTime } @@ -97,6 +115,15 @@ func (q *Queries) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]D &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, &i.UpdateState, &i.UpdateTimestamp, ); err != nil { @@ -153,49 +180,74 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([] const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, - htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value, + htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id WHERE swaps.swap_hash = $1 ` type GetStaticAddressLoopInSwapRow struct { - ID int32 - SwapHash []byte - Preimage []byte - InitiationTime time.Time - AmountRequested int64 - CltvExpiry int32 - MaxMinerFee int64 - MaxSwapFee int64 - InitiationHeight int32 - ProtocolVersion int32 - Label string - ID_2 int32 - SwapHash_2 []byte - SwapInvoice string - LastHop []byte - PaymentTimeoutSeconds int32 - QuotedSwapFeeSatoshis int64 - DepositOutpoints string - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString - HtlcTimeoutSweepAddress string - SelectedAmount int64 - Fast bool - SwapHash_3 []byte - SenderScriptPubkey []byte - ReceiverScriptPubkey []byte - SenderInternalPubkey []byte - ReceiverInternalPubkey []byte - ClientKeyFamily int32 - ClientKeyIndex int32 + ID int32 + SwapHash []byte + Preimage []byte + InitiationTime time.Time + AmountRequested int64 + CltvExpiry int32 + MaxMinerFee int64 + MaxSwapFee int64 + InitiationHeight int32 + ProtocolVersion int32 + Label string + ID_2 int32 + SwapHash_2 []byte + SwapInvoice string + LastHop []byte + PaymentTimeoutSeconds int32 + QuotedSwapFeeSatoshis int64 + DepositOutpoints string + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + HtlcTimeoutSweepAddress string + SelectedAmount int64 + Fast bool + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 + SwapHash_3 []byte + SenderScriptPubkey []byte + ReceiverScriptPubkey []byte + SenderInternalPubkey []byte + ReceiverInternalPubkey []byte + ClientKeyFamily int32 + ClientKeyIndex int32 + ChangeClientPubkey []byte + ChangeServerPubkey []byte + ChangeExpiry sql.NullInt32 + ChangeClientKeyFamily sql.NullInt32 + ChangeClientKeyIndex sql.NullInt32 + ChangePkscript []byte + ChangeProtocolVersion sql.NullInt32 + ChangeInitiationHeight sql.NullInt32 } func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) { @@ -225,6 +277,12 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.HtlcTimeoutSweepAddress, &i.SelectedAmount, &i.Fast, + &i.ConfirmationRiskDecision, + &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -232,6 +290,14 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.ReceiverInternalPubkey, &i.ClientKeyFamily, &i.ClientKeyIndex, + &i.ChangeClientPubkey, + &i.ChangeServerPubkey, + &i.ChangeExpiry, + &i.ChangeClientKeyFamily, + &i.ChangeClientKeyIndex, + &i.ChangePkscript, + &i.ChangeProtocolVersion, + &i.ChangeInitiationHeight, ) return i, err } @@ -239,14 +305,25 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, - htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value, + htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id JOIN static_address_swap_updates u ON swaps.swap_hash = u.swap_hash -- This subquery ensures that we are checking only the latest update for @@ -263,36 +340,50 @@ ORDER BY ` type GetStaticAddressLoopInSwapsByStatesRow struct { - ID int32 - SwapHash []byte - Preimage []byte - InitiationTime time.Time - AmountRequested int64 - CltvExpiry int32 - MaxMinerFee int64 - MaxSwapFee int64 - InitiationHeight int32 - ProtocolVersion int32 - Label string - ID_2 int32 - SwapHash_2 []byte - SwapInvoice string - LastHop []byte - PaymentTimeoutSeconds int32 - QuotedSwapFeeSatoshis int64 - DepositOutpoints string - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString - HtlcTimeoutSweepAddress string - SelectedAmount int64 - Fast bool - SwapHash_3 []byte - SenderScriptPubkey []byte - ReceiverScriptPubkey []byte - SenderInternalPubkey []byte - ReceiverInternalPubkey []byte - ClientKeyFamily int32 - ClientKeyIndex int32 + ID int32 + SwapHash []byte + Preimage []byte + InitiationTime time.Time + AmountRequested int64 + CltvExpiry int32 + MaxMinerFee int64 + MaxSwapFee int64 + InitiationHeight int32 + ProtocolVersion int32 + Label string + ID_2 int32 + SwapHash_2 []byte + SwapInvoice string + LastHop []byte + PaymentTimeoutSeconds int32 + QuotedSwapFeeSatoshis int64 + DepositOutpoints string + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + HtlcTimeoutSweepAddress string + SelectedAmount int64 + Fast bool + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 + SwapHash_3 []byte + SenderScriptPubkey []byte + ReceiverScriptPubkey []byte + SenderInternalPubkey []byte + ReceiverInternalPubkey []byte + ClientKeyFamily int32 + ClientKeyIndex int32 + ChangeClientPubkey []byte + ChangeServerPubkey []byte + ChangeExpiry sql.NullInt32 + ChangeClientKeyFamily sql.NullInt32 + ChangeClientKeyIndex sql.NullInt32 + ChangePkscript []byte + ChangeProtocolVersion sql.NullInt32 + ChangeInitiationHeight sql.NullInt32 } func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) { @@ -328,6 +419,12 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.HtlcTimeoutSweepAddress, &i.SelectedAmount, &i.Fast, + &i.ConfirmationRiskDecision, + &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -335,6 +432,14 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.ReceiverInternalPubkey, &i.ClientKeyFamily, &i.ClientKeyIndex, + &i.ChangeClientPubkey, + &i.ChangeServerPubkey, + &i.ChangeExpiry, + &i.ChangeClientKeyFamily, + &i.ChangeClientKeyIndex, + &i.ChangePkscript, + &i.ChangeProtocolVersion, + &i.ChangeInitiationHeight, ); err != nil { return nil, err } @@ -361,7 +466,8 @@ INSERT INTO static_address_swaps ( htlc_tx_fee_rate_sat_kw, htlc_timeout_sweep_tx_id, htlc_timeout_sweep_address, - fast + fast, + change_static_address_id ) VALUES ( $1, $2, @@ -373,7 +479,8 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ) ` @@ -389,6 +496,7 @@ type InsertStaticAddressLoopInParams struct { HtlcTimeoutSweepTxID sql.NullString HtlcTimeoutSweepAddress string Fast bool + ChangeStaticAddressID sql.NullInt32 } func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStaticAddressLoopInParams) error { @@ -404,6 +512,7 @@ func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStati arg.HtlcTimeoutSweepTxID, arg.HtlcTimeoutSweepAddress, arg.Fast, + arg.ChangeStaticAddressID, ) return err } @@ -482,6 +591,26 @@ func (q *Queries) OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSe return err } +const recordStaticAddressRiskDecision = `-- name: RecordStaticAddressRiskDecision :exec +UPDATE static_address_swaps +SET + confirmation_risk_decision = $2, + confirmation_risk_decision_time = $3 +WHERE + swap_hash = $1 +` + +type RecordStaticAddressRiskDecisionParams struct { + SwapHash []byte + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime +} + +func (q *Queries) RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error { + _, err := q.db.ExecContext(ctx, recordStaticAddressRiskDecision, arg.SwapHash, arg.ConfirmationRiskDecision, arg.ConfirmationRiskDecisionTime) + return err +} + const swapHashForDepositID = `-- name: SwapHashForDepositID :one SELECT swap_hash @@ -502,18 +631,31 @@ const updateStaticAddressLoopIn = `-- name: UpdateStaticAddressLoopIn :exec UPDATE static_address_swaps SET htlc_tx_fee_rate_sat_kw = $2, - htlc_timeout_sweep_tx_id = $3 + htlc_timeout_sweep_tx_id = $3, + confirmed_htlc_tx_id = $4, + confirmed_htlc_output_index = $5, + confirmed_htlc_output_value = $6 WHERE swap_hash = $1 ` type UpdateStaticAddressLoopInParams struct { - SwapHash []byte - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString + SwapHash []byte + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 } func (q *Queries) UpdateStaticAddressLoopIn(ctx context.Context, arg UpdateStaticAddressLoopInParams) error { - _, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn, arg.SwapHash, arg.HtlcTxFeeRateSatKw, arg.HtlcTimeoutSweepTxID) + _, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn, + arg.SwapHash, + arg.HtlcTxFeeRateSatKw, + arg.HtlcTimeoutSweepTxID, + arg.ConfirmedHtlcTxID, + arg.ConfirmedHtlcOutputIndex, + arg.ConfirmedHtlcOutputValue, + ) return err } diff --git a/loopdb/sqlc/static_addresses.sql.go b/loopdb/sqlc/static_addresses.sql.go index 054c07364..dbdb0e271 100644 --- a/loopdb/sqlc/static_addresses.sql.go +++ b/loopdb/sqlc/static_addresses.sql.go @@ -11,6 +11,7 @@ import ( const allStaticAddresses = `-- name: AllStaticAddresses :many SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses +ORDER BY id ASC ` func (q *Queries) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) { @@ -93,6 +94,29 @@ func (q *Queries) CreateStaticAddress(ctx context.Context, arg CreateStaticAddre return err } +const getLegacyAddress = `-- name: GetLegacyAddress :one +SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses +ORDER BY id ASC +LIMIT 1 +` + +func (q *Queries) GetLegacyAddress(ctx context.Context) (StaticAddress, error) { + row := q.db.QueryRowContext(ctx, getLegacyAddress) + var i StaticAddress + err := row.Scan( + &i.ID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, + ) + return i, err +} + const getStaticAddress = `-- name: GetStaticAddress :one SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses WHERE pkscript=$1 @@ -114,3 +138,15 @@ func (q *Queries) GetStaticAddress(ctx context.Context, pkscript []byte) (Static ) return i, err } + +const getStaticAddressID = `-- name: GetStaticAddressID :one +SELECT id FROM static_addresses +WHERE pkscript=$1 +` + +func (q *Queries) GetStaticAddressID(ctx context.Context, pkscript []byte) (int32, error) { + row := q.db.QueryRowContext(ctx, getStaticAddressID, pkscript) + var id int32 + err := row.Scan(&id) + return id, err +} diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 35ae71c82..ae9167f50 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -2962,6 +2962,343 @@ 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 RecoverDepositRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The transaction ID that created the static-address deposit output. + Txid string `protobuf:"bytes,1,opt,name=txid,proto3" json:"txid,omitempty"` + // The output index in the transaction. + Vout uint32 `protobuf:"varint,2,opt,name=vout,proto3" json:"vout,omitempty"` + // The block height hint used when registering the confirmation notification. + HeightHint int32 `protobuf:"varint,3,opt,name=height_hint,json=heightHint,proto3" json:"height_hint,omitempty"` + // The expected static-address P2TR pkScript encoded as hex. + PkscriptHex string `protobuf:"bytes,4,opt,name=pkscript_hex,json=pkscriptHex,proto3" json:"pkscript_hex,omitempty"` + // Optional highest child index to scan in each static-address key family. If + // unset, loopd uses its default recovery scan limit. + ScanLimit uint32 `protobuf:"varint,5,opt,name=scan_limit,json=scanLimit,proto3" json:"scan_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecoverDepositRequest) Reset() { + *x = RecoverDepositRequest{} + mi := &file_client_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecoverDepositRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecoverDepositRequest) ProtoMessage() {} + +func (x *RecoverDepositRequest) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[32] + 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 RecoverDepositRequest.ProtoReflect.Descriptor instead. +func (*RecoverDepositRequest) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{32} +} + +func (x *RecoverDepositRequest) GetTxid() string { + if x != nil { + return x.Txid + } + return "" +} + +func (x *RecoverDepositRequest) GetVout() uint32 { + if x != nil { + return x.Vout + } + return 0 +} + +func (x *RecoverDepositRequest) GetHeightHint() int32 { + if x != nil { + return x.HeightHint + } + return 0 +} + +func (x *RecoverDepositRequest) GetPkscriptHex() string { + if x != nil { + return x.PkscriptHex + } + return "" +} + +func (x *RecoverDepositRequest) GetScanLimit() uint32 { + if x != nil { + return x.ScanLimit + } + return 0 +} + +type RecoverDepositResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The recovered deposit outpoint in the form txid:index. + Outpoint string `protobuf:"bytes,1,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + // The recovered output value in satoshis. + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + // The block height at which the deposit transaction confirmed. + ConfirmationHeight int64 `protobuf:"varint,3,opt,name=confirmation_height,json=confirmationHeight,proto3" json:"confirmation_height,omitempty"` + // The matched client key family. + ClientKeyFamily int32 `protobuf:"varint,4,opt,name=client_key_family,json=clientKeyFamily,proto3" json:"client_key_family,omitempty"` + // The matched client key index. + ClientKeyIndex uint32 `protobuf:"varint,5,opt,name=client_key_index,json=clientKeyIndex,proto3" json:"client_key_index,omitempty"` + // The static address that owns the recovered deposit. + StaticAddress string `protobuf:"bytes,6,opt,name=static_address,json=staticAddress,proto3" json:"static_address,omitempty"` + // Whether a static-address row or wallet import was restored. + RecoveredAddress bool `protobuf:"varint,7,opt,name=recovered_address,json=recoveredAddress,proto3" json:"recovered_address,omitempty"` + // Whether a deposit row was created or reactivated. + RecoveredDeposit bool `protobuf:"varint,8,opt,name=recovered_deposit,json=recoveredDeposit,proto3" json:"recovered_deposit,omitempty"` + // The recovered deposit ID. + DepositId []byte `protobuf:"bytes,9,opt,name=deposit_id,json=depositId,proto3" json:"deposit_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecoverDepositResponse) Reset() { + *x = RecoverDepositResponse{} + mi := &file_client_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecoverDepositResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecoverDepositResponse) ProtoMessage() {} + +func (x *RecoverDepositResponse) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[33] + 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 RecoverDepositResponse.ProtoReflect.Descriptor instead. +func (*RecoverDepositResponse) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{33} +} + +func (x *RecoverDepositResponse) GetOutpoint() string { + if x != nil { + return x.Outpoint + } + return "" +} + +func (x *RecoverDepositResponse) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *RecoverDepositResponse) GetConfirmationHeight() int64 { + if x != nil { + return x.ConfirmationHeight + } + return 0 +} + +func (x *RecoverDepositResponse) GetClientKeyFamily() int32 { + if x != nil { + return x.ClientKeyFamily + } + return 0 +} + +func (x *RecoverDepositResponse) GetClientKeyIndex() uint32 { + if x != nil { + return x.ClientKeyIndex + } + return 0 +} + +func (x *RecoverDepositResponse) GetStaticAddress() string { + if x != nil { + return x.StaticAddress + } + return "" +} + +func (x *RecoverDepositResponse) GetRecoveredAddress() bool { + if x != nil { + return x.RecoveredAddress + } + return false +} + +func (x *RecoverDepositResponse) GetRecoveredDeposit() bool { + if x != nil { + return x.RecoveredDeposit + } + return false +} + +func (x *RecoverDepositResponse) GetDepositId() []byte { + if x != nil { + return x.DepositId + } + return nil +} + type L402Token struct { state protoimpl.MessageState `protogen:"open.v1"` // The base macaroon that was baked by the auth server. @@ -2991,7 +3328,7 @@ type L402Token struct { func (x *L402Token) Reset() { *x = L402Token{} - mi := &file_client_proto_msgTypes[30] + mi := &file_client_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3003,7 +3340,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[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3016,7 +3353,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{34} } func (x *L402Token) GetBaseMacaroon() []byte { @@ -3100,7 +3437,7 @@ type LoopStats struct { func (x *LoopStats) Reset() { *x = LoopStats{} - mi := &file_client_proto_msgTypes[31] + mi := &file_client_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3112,7 +3449,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[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3125,7 +3462,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{35} } func (x *LoopStats) GetPendingCount() uint64 { @@ -3171,7 +3508,7 @@ type GetInfoRequest struct { func (x *GetInfoRequest) Reset() { *x = GetInfoRequest{} - mi := &file_client_proto_msgTypes[32] + mi := &file_client_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3183,7 +3520,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[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3196,7 +3533,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{36} } type GetInfoResponse struct { @@ -3227,7 +3564,7 @@ type GetInfoResponse struct { func (x *GetInfoResponse) Reset() { *x = GetInfoResponse{} - mi := &file_client_proto_msgTypes[33] + mi := &file_client_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3239,7 +3576,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[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3252,7 +3589,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{37} } func (x *GetInfoResponse) GetVersion() string { @@ -3326,7 +3663,7 @@ type GetLiquidityParamsRequest struct { func (x *GetLiquidityParamsRequest) Reset() { *x = GetLiquidityParamsRequest{} - mi := &file_client_proto_msgTypes[34] + mi := &file_client_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3338,7 +3675,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[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3351,7 +3688,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{38} } type LiquidityParameters struct { @@ -3461,7 +3798,7 @@ type LiquidityParameters struct { func (x *LiquidityParameters) Reset() { *x = LiquidityParameters{} - mi := &file_client_proto_msgTypes[35] + mi := &file_client_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3473,7 +3810,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[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3486,7 +3823,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{39} } func (x *LiquidityParameters) GetRules() []*LiquidityRule { @@ -3696,7 +4033,7 @@ type EasyAssetAutoloopParams struct { func (x *EasyAssetAutoloopParams) Reset() { *x = EasyAssetAutoloopParams{} - mi := &file_client_proto_msgTypes[36] + mi := &file_client_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3708,7 +4045,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[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3721,7 +4058,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{40} } func (x *EasyAssetAutoloopParams) GetEnabled() bool { @@ -3765,7 +4102,7 @@ type LiquidityRule struct { func (x *LiquidityRule) Reset() { *x = LiquidityRule{} - mi := &file_client_proto_msgTypes[37] + mi := &file_client_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3777,7 +4114,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[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3790,7 +4127,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{41} } func (x *LiquidityRule) GetChannelId() uint64 { @@ -3848,7 +4185,7 @@ type SetLiquidityParamsRequest struct { func (x *SetLiquidityParamsRequest) Reset() { *x = SetLiquidityParamsRequest{} - mi := &file_client_proto_msgTypes[38] + mi := &file_client_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3860,7 +4197,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[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3873,7 +4210,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{42} } func (x *SetLiquidityParamsRequest) GetParameters() *LiquidityParameters { @@ -3891,7 +4228,7 @@ type SetLiquidityParamsResponse struct { func (x *SetLiquidityParamsResponse) Reset() { *x = SetLiquidityParamsResponse{} - mi := &file_client_proto_msgTypes[39] + mi := &file_client_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3903,7 +4240,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[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3916,7 +4253,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{43} } type SuggestSwapsRequest struct { @@ -3927,7 +4264,7 @@ type SuggestSwapsRequest struct { func (x *SuggestSwapsRequest) Reset() { *x = SuggestSwapsRequest{} - mi := &file_client_proto_msgTypes[40] + mi := &file_client_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3939,7 +4276,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[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3952,7 +4289,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{44} } type Disqualified struct { @@ -3969,7 +4306,7 @@ type Disqualified struct { func (x *Disqualified) Reset() { *x = Disqualified{} - mi := &file_client_proto_msgTypes[41] + mi := &file_client_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3981,7 +4318,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[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3994,7 +4331,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{45} } func (x *Disqualified) GetChannelId() uint64 { @@ -4033,7 +4370,7 @@ type SuggestSwapsResponse struct { func (x *SuggestSwapsResponse) Reset() { *x = SuggestSwapsResponse{} - mi := &file_client_proto_msgTypes[42] + mi := &file_client_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4045,7 +4382,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[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4058,7 +4395,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{46} } func (x *SuggestSwapsResponse) GetLoopOut() []*LoopOutRequest { @@ -4097,7 +4434,7 @@ type AbandonSwapRequest struct { func (x *AbandonSwapRequest) Reset() { *x = AbandonSwapRequest{} - mi := &file_client_proto_msgTypes[43] + mi := &file_client_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4109,7 +4446,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[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4122,7 +4459,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{47} } func (x *AbandonSwapRequest) GetId() []byte { @@ -4147,7 +4484,7 @@ type AbandonSwapResponse struct { func (x *AbandonSwapResponse) Reset() { *x = AbandonSwapResponse{} - mi := &file_client_proto_msgTypes[44] + mi := &file_client_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4159,7 +4496,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[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4172,7 +4509,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{48} } type ListReservationsRequest struct { @@ -4183,7 +4520,7 @@ type ListReservationsRequest struct { func (x *ListReservationsRequest) Reset() { *x = ListReservationsRequest{} - mi := &file_client_proto_msgTypes[45] + mi := &file_client_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4195,7 +4532,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[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4208,7 +4545,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{49} } type ListReservationsResponse struct { @@ -4221,7 +4558,7 @@ type ListReservationsResponse struct { func (x *ListReservationsResponse) Reset() { *x = ListReservationsResponse{} - mi := &file_client_proto_msgTypes[46] + mi := &file_client_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4233,7 +4570,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[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4246,7 +4583,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{50} } func (x *ListReservationsResponse) GetReservations() []*ClientReservation { @@ -4276,7 +4613,7 @@ type ClientReservation struct { func (x *ClientReservation) Reset() { *x = ClientReservation{} - mi := &file_client_proto_msgTypes[47] + mi := &file_client_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4288,7 +4625,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[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4301,7 +4638,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{51} } func (x *ClientReservation) GetReservationId() []byte { @@ -4363,7 +4700,7 @@ type InstantOutRequest struct { func (x *InstantOutRequest) Reset() { *x = InstantOutRequest{} - mi := &file_client_proto_msgTypes[48] + mi := &file_client_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4375,7 +4712,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[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4388,7 +4725,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{52} } func (x *InstantOutRequest) GetReservationIds() [][]byte { @@ -4426,7 +4763,7 @@ type InstantOutResponse struct { func (x *InstantOutResponse) Reset() { *x = InstantOutResponse{} - mi := &file_client_proto_msgTypes[49] + mi := &file_client_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4438,7 +4775,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[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4451,7 +4788,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{53} } func (x *InstantOutResponse) GetInstantOutHash() []byte { @@ -4492,7 +4829,7 @@ type InstantOutQuoteRequest struct { func (x *InstantOutQuoteRequest) Reset() { *x = InstantOutQuoteRequest{} - mi := &file_client_proto_msgTypes[50] + mi := &file_client_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4504,7 +4841,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[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4517,7 +4854,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{54} } func (x *InstantOutQuoteRequest) GetAmt() uint64 { @@ -4555,7 +4892,7 @@ type InstantOutQuoteResponse struct { func (x *InstantOutQuoteResponse) Reset() { *x = InstantOutQuoteResponse{} - mi := &file_client_proto_msgTypes[51] + mi := &file_client_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4567,7 +4904,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[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4580,7 +4917,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{55} } func (x *InstantOutQuoteResponse) GetServiceFeeSat() int64 { @@ -4605,7 +4942,7 @@ type ListInstantOutsRequest struct { func (x *ListInstantOutsRequest) Reset() { *x = ListInstantOutsRequest{} - mi := &file_client_proto_msgTypes[52] + mi := &file_client_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4617,7 +4954,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[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4630,7 +4967,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{56} } type ListInstantOutsResponse struct { @@ -4643,7 +4980,7 @@ type ListInstantOutsResponse struct { func (x *ListInstantOutsResponse) Reset() { *x = ListInstantOutsResponse{} - mi := &file_client_proto_msgTypes[53] + mi := &file_client_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4655,7 +4992,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[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4668,7 +5005,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{57} } func (x *ListInstantOutsResponse) GetSwaps() []*InstantOut { @@ -4696,7 +5033,7 @@ type InstantOut struct { func (x *InstantOut) Reset() { *x = InstantOut{} - mi := &file_client_proto_msgTypes[54] + mi := &file_client_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4708,7 +5045,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[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4721,7 +5058,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{58} } func (x *InstantOut) GetSwapHash() []byte { @@ -4762,14 +5099,19 @@ func (x *InstantOut) GetSweepTxId() string { type NewStaticAddressRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The client's public key for the 2-of-2 MuSig2 taproot static address. - ClientKey []byte `protobuf:"bytes,1,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ClientKey []byte `protobuf:"bytes,1,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` + // If set, loopd initiates a deposit by calling lnd's SendCoins API. If the + // request's addr field is empty, loopd creates and funds a new static + // address. If addr is set, it must be an existing static address known to + // loopd. + SendCoinsRequest *lnrpc.SendCoinsRequest `protobuf:"bytes,2,opt,name=send_coins_request,json=sendCoinsRequest,proto3" json:"send_coins_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NewStaticAddressRequest) Reset() { *x = NewStaticAddressRequest{} - mi := &file_client_proto_msgTypes[55] + mi := &file_client_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4781,7 +5123,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[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4794,7 +5136,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{59} } func (x *NewStaticAddressRequest) GetClientKey() []byte { @@ -4804,19 +5146,28 @@ func (x *NewStaticAddressRequest) GetClientKey() []byte { return nil } +func (x *NewStaticAddressRequest) GetSendCoinsRequest() *lnrpc.SendCoinsRequest { + if x != nil { + return x.SendCoinsRequest + } + return nil +} + type NewStaticAddressResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The taproot static address. Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` // The CSV expiry of the static address. - Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"` + // The response from lnd's SendCoins API, if a deposit was initiated. + SendCoinsResponse *lnrpc.SendCoinsResponse `protobuf:"bytes,3,opt,name=send_coins_response,json=sendCoinsResponse,proto3" json:"send_coins_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NewStaticAddressResponse) Reset() { *x = NewStaticAddressResponse{} - mi := &file_client_proto_msgTypes[56] + mi := &file_client_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4828,7 +5179,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[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4841,7 +5192,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{60} } func (x *NewStaticAddressResponse) GetAddress() string { @@ -4858,6 +5209,13 @@ func (x *NewStaticAddressResponse) GetExpiry() uint32 { return 0 } +func (x *NewStaticAddressResponse) GetSendCoinsResponse() *lnrpc.SendCoinsResponse { + if x != nil { + return x.SendCoinsResponse + } + return nil +} + type ListUnspentDepositsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of minimum confirmations a utxo must have to be listed. @@ -4871,7 +5229,7 @@ type ListUnspentDepositsRequest struct { func (x *ListUnspentDepositsRequest) Reset() { *x = ListUnspentDepositsRequest{} - mi := &file_client_proto_msgTypes[57] + mi := &file_client_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4883,7 +5241,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[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4896,7 +5254,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{61} } func (x *ListUnspentDepositsRequest) GetMinConfs() int32 { @@ -4923,7 +5281,7 @@ type ListUnspentDepositsResponse struct { func (x *ListUnspentDepositsResponse) Reset() { *x = ListUnspentDepositsResponse{} - mi := &file_client_proto_msgTypes[58] + mi := &file_client_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4935,7 +5293,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[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4948,7 +5306,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{62} } func (x *ListUnspentDepositsResponse) GetUtxos() []*Utxo { @@ -4974,7 +5332,7 @@ type Utxo struct { func (x *Utxo) Reset() { *x = Utxo{} - mi := &file_client_proto_msgTypes[59] + mi := &file_client_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4986,7 +5344,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[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4999,7 +5357,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{63} } func (x *Utxo) GetStaticAddress() string { @@ -5052,7 +5410,7 @@ type WithdrawDepositsRequest struct { func (x *WithdrawDepositsRequest) Reset() { *x = WithdrawDepositsRequest{} - mi := &file_client_proto_msgTypes[60] + mi := &file_client_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5064,7 +5422,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[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5077,7 +5435,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{64} } func (x *WithdrawDepositsRequest) GetOutpoints() []*lnrpc.OutPoint { @@ -5127,7 +5485,7 @@ type WithdrawDepositsResponse struct { func (x *WithdrawDepositsResponse) Reset() { *x = WithdrawDepositsResponse{} - mi := &file_client_proto_msgTypes[61] + mi := &file_client_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5139,7 +5497,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[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5152,7 +5510,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{65} } func (x *WithdrawDepositsResponse) GetWithdrawalTxHash() string { @@ -5181,7 +5539,7 @@ type ListStaticAddressDepositsRequest struct { func (x *ListStaticAddressDepositsRequest) Reset() { *x = ListStaticAddressDepositsRequest{} - mi := &file_client_proto_msgTypes[62] + mi := &file_client_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5193,7 +5551,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[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5206,7 +5564,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{66} } func (x *ListStaticAddressDepositsRequest) GetStateFilter() DepositState { @@ -5233,7 +5591,7 @@ type ListStaticAddressDepositsResponse struct { func (x *ListStaticAddressDepositsResponse) Reset() { *x = ListStaticAddressDepositsResponse{} - mi := &file_client_proto_msgTypes[63] + mi := &file_client_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5245,7 +5603,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[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5258,7 +5616,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{67} } func (x *ListStaticAddressDepositsResponse) GetFilteredDeposits() []*Deposit { @@ -5276,7 +5634,7 @@ type ListStaticAddressWithdrawalRequest struct { func (x *ListStaticAddressWithdrawalRequest) Reset() { *x = ListStaticAddressWithdrawalRequest{} - mi := &file_client_proto_msgTypes[64] + mi := &file_client_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5288,7 +5646,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[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5301,7 +5659,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{68} } type ListStaticAddressWithdrawalResponse struct { @@ -5314,7 +5672,7 @@ type ListStaticAddressWithdrawalResponse struct { func (x *ListStaticAddressWithdrawalResponse) Reset() { *x = ListStaticAddressWithdrawalResponse{} - mi := &file_client_proto_msgTypes[65] + mi := &file_client_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5326,7 +5684,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[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5339,7 +5697,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{69} } func (x *ListStaticAddressWithdrawalResponse) GetWithdrawals() []*StaticAddressWithdrawal { @@ -5357,7 +5715,7 @@ type ListStaticAddressSwapsRequest struct { func (x *ListStaticAddressSwapsRequest) Reset() { *x = ListStaticAddressSwapsRequest{} - mi := &file_client_proto_msgTypes[66] + mi := &file_client_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5369,7 +5727,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[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5382,7 +5740,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{70} } type ListStaticAddressSwapsResponse struct { @@ -5395,7 +5753,7 @@ type ListStaticAddressSwapsResponse struct { func (x *ListStaticAddressSwapsResponse) Reset() { *x = ListStaticAddressSwapsResponse{} - mi := &file_client_proto_msgTypes[67] + mi := &file_client_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5407,7 +5765,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[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5420,7 +5778,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{71} } func (x *ListStaticAddressSwapsResponse) GetSwaps() []*StaticAddressLoopInSwap { @@ -5438,7 +5796,7 @@ type StaticAddressSummaryRequest struct { func (x *StaticAddressSummaryRequest) Reset() { *x = StaticAddressSummaryRequest{} - mi := &file_client_proto_msgTypes[68] + mi := &file_client_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5450,7 +5808,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[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5463,7 +5821,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{72} } type StaticAddressSummaryResponse struct { @@ -5494,7 +5852,7 @@ type StaticAddressSummaryResponse struct { func (x *StaticAddressSummaryResponse) Reset() { *x = StaticAddressSummaryResponse{} - mi := &file_client_proto_msgTypes[69] + mi := &file_client_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5506,7 +5864,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[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5519,7 +5877,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{73} } func (x *StaticAddressSummaryResponse) GetStaticAddress() string { @@ -5609,14 +5967,16 @@ type Deposit struct { BlocksUntilExpiry int64 `protobuf:"varint,6,opt,name=blocks_until_expiry,json=blocksUntilExpiry,proto3" json:"blocks_until_expiry,omitempty"` // The swap hash of the swap that this deposit is part of. This field is only // set if the deposit is part of a loop-in swap. - SwapHash []byte `protobuf:"bytes,7,opt,name=swap_hash,json=swapHash,proto3" json:"swap_hash,omitempty"` + SwapHash []byte `protobuf:"bytes,7,opt,name=swap_hash,json=swapHash,proto3" json:"swap_hash,omitempty"` + // The static address that the deposit was sent to. + StaticAddress string `protobuf:"bytes,8,opt,name=static_address,json=staticAddress,proto3" json:"static_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Deposit) Reset() { *x = Deposit{} - mi := &file_client_proto_msgTypes[70] + mi := &file_client_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5628,7 +5988,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[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5641,7 +6001,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{74} } func (x *Deposit) GetId() []byte { @@ -5693,6 +6053,13 @@ func (x *Deposit) GetSwapHash() []byte { return nil } +func (x *Deposit) GetStaticAddress() string { + if x != nil { + return x.StaticAddress + } + return "" +} + type StaticAddressWithdrawal struct { state protoimpl.MessageState `protogen:"open.v1"` // The transaction id of the withdrawal transaction. @@ -5715,7 +6082,7 @@ type StaticAddressWithdrawal struct { func (x *StaticAddressWithdrawal) Reset() { *x = StaticAddressWithdrawal{} - mi := &file_client_proto_msgTypes[71] + mi := &file_client_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5727,7 +6094,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[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5740,7 +6107,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{75} } func (x *StaticAddressWithdrawal) GetTxId() string { @@ -5805,7 +6172,7 @@ type StaticAddressLoopInSwap struct { func (x *StaticAddressLoopInSwap) Reset() { *x = StaticAddressLoopInSwap{} - mi := &file_client_proto_msgTypes[72] + mi := &file_client_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5817,7 +6184,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[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5830,7 +6197,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{76} } func (x *StaticAddressLoopInSwap) GetSwapHash() []byte { @@ -5928,7 +6295,7 @@ type StaticAddressLoopInRequest struct { func (x *StaticAddressLoopInRequest) Reset() { *x = StaticAddressLoopInRequest{} - mi := &file_client_proto_msgTypes[73] + mi := &file_client_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5940,7 +6307,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[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5953,7 +6320,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{77} } func (x *StaticAddressLoopInRequest) GetOutpoints() []string { @@ -6073,7 +6440,7 @@ type StaticAddressLoopInResponse struct { func (x *StaticAddressLoopInResponse) Reset() { *x = StaticAddressLoopInResponse{} - mi := &file_client_proto_msgTypes[74] + mi := &file_client_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6085,7 +6452,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[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6098,7 +6465,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{78} } func (x *StaticAddressLoopInResponse) GetSwapHash() []byte { @@ -6227,7 +6594,7 @@ type AssetLoopOutRequest struct { func (x *AssetLoopOutRequest) Reset() { *x = AssetLoopOutRequest{} - mi := &file_client_proto_msgTypes[75] + mi := &file_client_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6239,7 +6606,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[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6252,7 +6619,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{79} } func (x *AssetLoopOutRequest) GetAssetId() []byte { @@ -6307,7 +6674,7 @@ type AssetRfqInfo struct { func (x *AssetRfqInfo) Reset() { *x = AssetRfqInfo{} - mi := &file_client_proto_msgTypes[76] + mi := &file_client_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6319,7 +6686,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[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6332,7 +6699,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{80} } func (x *AssetRfqInfo) GetPrepayRfqId() []byte { @@ -6421,7 +6788,7 @@ type FixedPoint struct { func (x *FixedPoint) Reset() { *x = FixedPoint{} - mi := &file_client_proto_msgTypes[77] + mi := &file_client_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6433,7 +6800,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[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6446,7 +6813,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{81} } func (x *FixedPoint) GetCoefficient() string { @@ -6477,7 +6844,7 @@ type AssetLoopOutInfo struct { func (x *AssetLoopOutInfo) Reset() { *x = AssetLoopOutInfo{} - mi := &file_client_proto_msgTypes[78] + mi := &file_client_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6489,7 +6856,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[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6502,7 +6869,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{82} } func (x *AssetLoopOutInfo) GetAssetId() string { @@ -6700,7 +7067,37 @@ 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\"\xc9\x01\n" + + "\x15RecoverDepositRequest\x12\x12\n" + + "\x04txid\x18\x01 \x01(\tR\x04txid\x12\x12\n" + + "\x04vout\x18\x02 \x01(\rR\x04vout\x12\x1f\n" + + "\vheight_hint\x18\x03 \x01(\x05R\n" + + "heightHint\x12!\n" + + "\fpkscript_hex\x18\x04 \x01(\tR\vpkscriptHex\x12\x1d\n" + + "\n" + + "scan_limit\x18\x05 \x01(\rR\tscanLimitJ\x04\b\x06\x10\aJ\x04\b\a\x10\bR\x11server_pubkey_hexR\x06expiry\"\xf1\x02\n" + + "\x16RecoverDepositResponse\x12\x1a\n" + + "\boutpoint\x18\x01 \x01(\tR\boutpoint\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value\x12/\n" + + "\x13confirmation_height\x18\x03 \x01(\x03R\x12confirmationHeight\x12*\n" + + "\x11client_key_family\x18\x04 \x01(\x05R\x0fclientKeyFamily\x12(\n" + + "\x10client_key_index\x18\x05 \x01(\rR\x0eclientKeyIndex\x12%\n" + + "\x0estatic_address\x18\x06 \x01(\tR\rstaticAddress\x12+\n" + + "\x11recovered_address\x18\a \x01(\bR\x10recoveredAddress\x12+\n" + + "\x11recovered_deposit\x18\b \x01(\bR\x10recoveredDeposit\x12\x1d\n" + + "\n" + + "deposit_id\x18\t \x01(\fR\tdepositId\"\xcb\x02\n" + "\tL402Token\x12#\n" + "\rbase_macaroon\x18\x01 \x01(\fR\fbaseMacaroon\x12!\n" + "\fpayment_hash\x18\x02 \x01(\fR\vpaymentHash\x12)\n" + @@ -6829,13 +7226,15 @@ const file_client_proto_rawDesc = "" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x16\n" + "\x06amount\x18\x03 \x01(\x04R\x06amount\x12'\n" + "\x0freservation_ids\x18\x04 \x03(\fR\x0ereservationIds\x12\x1e\n" + - "\vsweep_tx_id\x18\x05 \x01(\tR\tsweepTxId\"8\n" + + "\vsweep_tx_id\x18\x05 \x01(\tR\tsweepTxId\"\x7f\n" + "\x17NewStaticAddressRequest\x12\x1d\n" + "\n" + - "client_key\x18\x01 \x01(\fR\tclientKey\"L\n" + + "client_key\x18\x01 \x01(\fR\tclientKey\x12E\n" + + "\x12send_coins_request\x18\x02 \x01(\v2\x17.lnrpc.SendCoinsRequestR\x10sendCoinsRequest\"\x96\x01\n" + "\x18NewStaticAddressResponse\x12\x18\n" + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x16\n" + - "\x06expiry\x18\x02 \x01(\rR\x06expiry\"V\n" + + "\x06expiry\x18\x02 \x01(\rR\x06expiry\x12H\n" + + "\x13send_coins_response\x18\x03 \x01(\v2\x18.lnrpc.SendCoinsResponseR\x11sendCoinsResponse\"V\n" + "\x1aListUnspentDepositsRequest\x12\x1b\n" + "\tmin_confs\x18\x01 \x01(\x05R\bminConfs\x12\x1b\n" + "\tmax_confs\x18\x02 \x01(\x05R\bmaxConfs\"B\n" + @@ -6879,7 +7278,7 @@ const file_client_proto_rawDesc = "" + "\x18value_looped_in_satoshis\x18\b \x01(\x03R\x15valueLoopedInSatoshis\x12J\n" + "\"value_htlc_timeout_sweeps_satoshis\x18\t \x01(\x03R\x1evalueHtlcTimeoutSweepsSatoshis\x122\n" + "\x15value_channels_opened\x18\n" + - " \x01(\x03R\x13valueChannelsOpened\"\xf6\x01\n" + + " \x01(\x03R\x13valueChannelsOpened\"\x9d\x02\n" + "\aDeposit\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x12+\n" + "\x05state\x18\x02 \x01(\x0e2\x15.looprpc.DepositStateR\x05state\x12\x1a\n" + @@ -6887,7 +7286,8 @@ const file_client_proto_rawDesc = "" + "\x05value\x18\x04 \x01(\x03R\x05value\x12/\n" + "\x13confirmation_height\x18\x05 \x01(\x03R\x12confirmationHeight\x12.\n" + "\x13blocks_until_expiry\x18\x06 \x01(\x03R\x11blocksUntilExpiry\x12\x1b\n" + - "\tswap_hash\x18\a \x01(\fR\bswapHash\"\xc2\x02\n" + + "\tswap_hash\x18\a \x01(\fR\bswapHash\x12%\n" + + "\x0estatic_address\x18\b \x01(\tR\rstaticAddress\"\xc2\x02\n" + "\x17StaticAddressWithdrawal\x12\x13\n" + "\x05tx_id\x18\x01 \x01(\tR\x04txId\x12,\n" + "\bdeposits\x18\x02 \x03(\v2\x10.looprpc.DepositR\bdeposits\x12A\n" + @@ -7030,7 +7430,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\xdb\x15\n" + "\n" + "SwapClient\x129\n" + "\aLoopOut\x12\x17.looprpc.LoopOutRequest\x1a\x15.looprpc.SwapResponse\x127\n" + @@ -7048,6 +7448,8 @@ 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\x12Q\n" + + "\x0eRecoverDeposit\x12\x1e.looprpc.RecoverDepositRequest\x1a\x1f.looprpc.RecoverDepositResponse\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 +7484,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, 84) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType @@ -7123,181 +7525,193 @@ 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 + (*RecoverDepositRequest)(nil), // 41: looprpc.RecoverDepositRequest + (*RecoverDepositResponse)(nil), // 42: looprpc.RecoverDepositResponse + (*L402Token)(nil), // 43: looprpc.L402Token + (*LoopStats)(nil), // 44: looprpc.LoopStats + (*GetInfoRequest)(nil), // 45: looprpc.GetInfoRequest + (*GetInfoResponse)(nil), // 46: looprpc.GetInfoResponse + (*GetLiquidityParamsRequest)(nil), // 47: looprpc.GetLiquidityParamsRequest + (*LiquidityParameters)(nil), // 48: looprpc.LiquidityParameters + (*EasyAssetAutoloopParams)(nil), // 49: looprpc.EasyAssetAutoloopParams + (*LiquidityRule)(nil), // 50: looprpc.LiquidityRule + (*SetLiquidityParamsRequest)(nil), // 51: looprpc.SetLiquidityParamsRequest + (*SetLiquidityParamsResponse)(nil), // 52: looprpc.SetLiquidityParamsResponse + (*SuggestSwapsRequest)(nil), // 53: looprpc.SuggestSwapsRequest + (*Disqualified)(nil), // 54: looprpc.Disqualified + (*SuggestSwapsResponse)(nil), // 55: looprpc.SuggestSwapsResponse + (*AbandonSwapRequest)(nil), // 56: looprpc.AbandonSwapRequest + (*AbandonSwapResponse)(nil), // 57: looprpc.AbandonSwapResponse + (*ListReservationsRequest)(nil), // 58: looprpc.ListReservationsRequest + (*ListReservationsResponse)(nil), // 59: looprpc.ListReservationsResponse + (*ClientReservation)(nil), // 60: looprpc.ClientReservation + (*InstantOutRequest)(nil), // 61: looprpc.InstantOutRequest + (*InstantOutResponse)(nil), // 62: looprpc.InstantOutResponse + (*InstantOutQuoteRequest)(nil), // 63: looprpc.InstantOutQuoteRequest + (*InstantOutQuoteResponse)(nil), // 64: looprpc.InstantOutQuoteResponse + (*ListInstantOutsRequest)(nil), // 65: looprpc.ListInstantOutsRequest + (*ListInstantOutsResponse)(nil), // 66: looprpc.ListInstantOutsResponse + (*InstantOut)(nil), // 67: looprpc.InstantOut + (*NewStaticAddressRequest)(nil), // 68: looprpc.NewStaticAddressRequest + (*NewStaticAddressResponse)(nil), // 69: looprpc.NewStaticAddressResponse + (*ListUnspentDepositsRequest)(nil), // 70: looprpc.ListUnspentDepositsRequest + (*ListUnspentDepositsResponse)(nil), // 71: looprpc.ListUnspentDepositsResponse + (*Utxo)(nil), // 72: looprpc.Utxo + (*WithdrawDepositsRequest)(nil), // 73: looprpc.WithdrawDepositsRequest + (*WithdrawDepositsResponse)(nil), // 74: looprpc.WithdrawDepositsResponse + (*ListStaticAddressDepositsRequest)(nil), // 75: looprpc.ListStaticAddressDepositsRequest + (*ListStaticAddressDepositsResponse)(nil), // 76: looprpc.ListStaticAddressDepositsResponse + (*ListStaticAddressWithdrawalRequest)(nil), // 77: looprpc.ListStaticAddressWithdrawalRequest + (*ListStaticAddressWithdrawalResponse)(nil), // 78: looprpc.ListStaticAddressWithdrawalResponse + (*ListStaticAddressSwapsRequest)(nil), // 79: looprpc.ListStaticAddressSwapsRequest + (*ListStaticAddressSwapsResponse)(nil), // 80: looprpc.ListStaticAddressSwapsResponse + (*StaticAddressSummaryRequest)(nil), // 81: looprpc.StaticAddressSummaryRequest + (*StaticAddressSummaryResponse)(nil), // 82: looprpc.StaticAddressSummaryResponse + (*Deposit)(nil), // 83: looprpc.Deposit + (*StaticAddressWithdrawal)(nil), // 84: looprpc.StaticAddressWithdrawal + (*StaticAddressLoopInSwap)(nil), // 85: looprpc.StaticAddressLoopInSwap + (*StaticAddressLoopInRequest)(nil), // 86: looprpc.StaticAddressLoopInRequest + (*StaticAddressLoopInResponse)(nil), // 87: looprpc.StaticAddressLoopInResponse + (*AssetLoopOutRequest)(nil), // 88: looprpc.AssetLoopOutRequest + (*AssetRfqInfo)(nil), // 89: looprpc.AssetRfqInfo + (*FixedPoint)(nil), // 90: looprpc.FixedPoint + (*AssetLoopOutInfo)(nil), // 91: looprpc.AssetLoopOutInfo + nil, // 92: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 93: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 94: looprpc.RouteHint + (*lnrpc.SendCoinsRequest)(nil), // 95: lnrpc.SendCoinsRequest + (*lnrpc.SendCoinsResponse)(nil), // 96: lnrpc.SendCoinsResponse + (*lnrpc.OutPoint)(nil), // 97: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 89, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 93, // 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 + 88, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 89, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 94, // 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 + 91, // 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 + 94, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 88, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 89, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 94, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 43, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 44, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 44, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 50, // 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 + 92, // 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 + 48, // 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 - 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 - 6, // 40: looprpc.Deposit.state:type_name -> looprpc.DepositState - 79, // 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 - 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 - 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 - 30, // 59: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 33, // 60: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 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 - 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 + 54, // 31: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 60, // 32: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 67, // 33: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 95, // 34: looprpc.NewStaticAddressRequest.send_coins_request:type_name -> lnrpc.SendCoinsRequest + 96, // 35: looprpc.NewStaticAddressResponse.send_coins_response:type_name -> lnrpc.SendCoinsResponse + 72, // 36: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 97, // 37: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 6, // 38: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 83, // 39: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 84, // 40: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 85, // 41: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 6, // 42: looprpc.Deposit.state:type_name -> looprpc.DepositState + 83, // 43: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 7, // 44: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 83, // 45: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 94, // 46: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 83, // 47: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 90, // 48: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 90, // 49: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 49, // 50: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 13, // 51: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 14, // 52: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 16, // 53: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 18, // 54: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 21, // 55: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 26, // 56: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 56, // 57: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 27, // 58: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 30, // 59: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 27, // 60: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 30, // 61: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 33, // 62: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 35, // 63: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 35, // 64: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 37, // 65: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 39, // 66: looprpc.SwapClient.Recover:input_type -> looprpc.RecoverRequest + 41, // 67: looprpc.SwapClient.RecoverDeposit:input_type -> looprpc.RecoverDepositRequest + 45, // 68: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 11, // 69: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 47, // 70: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 51, // 71: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 53, // 72: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 58, // 73: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 61, // 74: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 63, // 75: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 65, // 76: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 68, // 77: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 70, // 78: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 73, // 79: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 75, // 80: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 77, // 81: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 79, // 82: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 81, // 83: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 86, // 84: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 9, // 85: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 15, // 86: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 15, // 87: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 17, // 88: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 20, // 89: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 22, // 90: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 17, // 91: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 57, // 92: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 29, // 93: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 32, // 94: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 28, // 95: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 31, // 96: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 34, // 97: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 36, // 98: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 36, // 99: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 38, // 100: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 40, // 101: looprpc.SwapClient.Recover:output_type -> looprpc.RecoverResponse + 42, // 102: looprpc.SwapClient.RecoverDeposit:output_type -> looprpc.RecoverDepositResponse + 46, // 103: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 12, // 104: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 48, // 105: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 52, // 106: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 55, // 107: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 59, // 108: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 62, // 109: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 64, // 110: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 66, // 111: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 69, // 112: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 71, // 113: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 74, // 114: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 76, // 115: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 78, // 116: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 80, // 117: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 82, // 118: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 87, // 119: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 10, // 120: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 86, // [86:121] is the sub-list for method output_type + 51, // [51:86] is the sub-list for method input_type + 51, // [51:51] is the sub-list for extension type_name + 51, // [51:51] is the sub-list for extension extendee + 0, // [0:51] is the sub-list for field type_name } func init() { file_client_proto_init() } @@ -7316,7 +7730,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: 84, NumExtensions: 0, NumServices: 1, }, diff --git a/looprpc/client.pb.gw.go b/looprpc/client.pb.gw.go index c2ef63f88..31827d1d0 100644 --- a/looprpc/client.pb.gw.go +++ b/looprpc/client.pb.gw.go @@ -479,6 +479,58 @@ 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_RecoverDeposit_0(ctx context.Context, marshaler runtime.Marshaler, client SwapClientClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RecoverDepositRequest + 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.RecoverDeposit(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_SwapClient_RecoverDeposit_0(ctx context.Context, marshaler runtime.Marshaler, server SwapClientServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RecoverDepositRequest + 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.RecoverDeposit(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 +1227,56 @@ 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("POST", pattern_SwapClient_RecoverDeposit_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/RecoverDeposit", runtime.WithHTTPPathPattern("/v1/recover/deposit")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_SwapClient_RecoverDeposit_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_RecoverDeposit_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 +2007,50 @@ 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("POST", pattern_SwapClient_RecoverDeposit_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/RecoverDeposit", runtime.WithHTTPPathPattern("/v1/recover/deposit")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_SwapClient_RecoverDeposit_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_RecoverDeposit_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 +2453,10 @@ 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_RecoverDeposit_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "recover", "deposit"}, "")) + 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 +2517,10 @@ var ( forward_SwapClient_GetL402Tokens_1 = runtime.ForwardResponseMessage + forward_SwapClient_Recover_0 = runtime.ForwardResponseMessage + + forward_SwapClient_RecoverDeposit_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..8293391ee 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -102,6 +102,18 @@ 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: `recoverdeposit` + RecoverDeposit verifies one static-address deposit on-chain and restores + its local address/deposit state without relying on wallet reconciliation. + */ + rpc RecoverDeposit (RecoverDepositRequest) returns (RecoverDepositResponse); + /* loop: `getinfo` GetInfo gets basic information about the loop daemon. */ @@ -1060,6 +1072,126 @@ 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 RecoverDepositRequest { + reserved 6, 7; + reserved "server_pubkey_hex", "expiry"; + + /* + The transaction ID that created the static-address deposit output. + */ + string txid = 1; + + /* + The output index in the transaction. + */ + uint32 vout = 2; + + /* + The block height hint used when registering the confirmation notification. + */ + int32 height_hint = 3; + + /* + The expected static-address P2TR pkScript encoded as hex. + */ + string pkscript_hex = 4; + + /* + Optional highest child index to scan in each static-address key family. If + unset, loopd uses its default recovery scan limit. + */ + uint32 scan_limit = 5; +} + +message RecoverDepositResponse { + /* + The recovered deposit outpoint in the form txid:index. + */ + string outpoint = 1; + + /* + The recovered output value in satoshis. + */ + int64 value = 2; + + /* + The block height at which the deposit transaction confirmed. + */ + int64 confirmation_height = 3; + + /* + The matched client key family. + */ + int32 client_key_family = 4; + + /* + The matched client key index. + */ + uint32 client_key_index = 5; + + /* + The static address that owns the recovered deposit. + */ + string static_address = 6; + + /* + Whether a static-address row or wallet import was restored. + */ + bool recovered_address = 7; + + /* + Whether a deposit row was created or reactivated. + */ + bool recovered_deposit = 8; + + /* + The recovered deposit ID. + */ + bytes deposit_id = 9; +} + message L402Token { /* The base macaroon that was baked by the auth server. @@ -1733,6 +1865,14 @@ message NewStaticAddressRequest { The client's public key for the 2-of-2 MuSig2 taproot static address. */ bytes client_key = 1; + + /* + If set, loopd initiates a deposit by calling lnd's SendCoins API. If the + request's addr field is empty, loopd creates and funds a new static + address. If addr is set, it must be an existing static address known to + loopd. + */ + lnrpc.SendCoinsRequest send_coins_request = 2; } message NewStaticAddressResponse { @@ -1745,6 +1885,11 @@ message NewStaticAddressResponse { The CSV expiry of the static address. */ uint32 expiry = 2; + + /* + The response from lnd's SendCoins API, if a deposit was initiated. + */ + lnrpc.SendCoinsResponse send_coins_response = 3; } message ListUnspentDepositsRequest { @@ -2043,6 +2188,11 @@ message Deposit { set if the deposit is part of a loop-in swap. */ bytes swap_hash = 7; + + /* + The static address that the deposit was sent to. + */ + string static_address = 8; } message StaticAddressWithdrawal { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 3d75d15da..cfeaec078 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -868,6 +868,72 @@ ] } }, + "/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/recover/deposit": { + "post": { + "summary": "loop: `recoverdeposit`\nRecoverDeposit verifies one static-address deposit on-chain and restores\nits local address/deposit state without relying on wallet reconciliation.", + "operationId": "SwapClient_RecoverDeposit", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/looprpcRecoverDepositResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/looprpcRecoverDepositRequest" + } + } + ], + "tags": [ + "SwapClient" + ] + } + }, "/v1/staticaddr": { "post": { "summary": "loop: `static newstaticaddress`\nNewStaticAddress requests a new static address for loop-ins from the server.", @@ -1204,6 +1270,16 @@ } } }, + "lnrpcCoinSelectionStrategy": { + "type": "string", + "enum": [ + "STRATEGY_USE_GLOBAL_CONFIG", + "STRATEGY_LARGEST", + "STRATEGY_RANDOM" + ], + "default": "STRATEGY_USE_GLOBAL_CONFIG", + "description": " - STRATEGY_USE_GLOBAL_CONFIG: Use the coin selection strategy defined in the global configuration\n(lnd.conf).\n - STRATEGY_LARGEST: Select the largest available coins first during coin selection.\n - STRATEGY_RANDOM: Randomly select the available coins during coin selection." + }, "lnrpcCommitmentType": { "type": "string", "enum": [ @@ -1434,6 +1510,73 @@ } } }, + "lnrpcSendCoinsRequest": { + "type": "object", + "properties": { + "addr": { + "type": "string", + "title": "The address to send coins to" + }, + "amount": { + "type": "string", + "format": "int64", + "title": "The amount in satoshis to send" + }, + "target_conf": { + "type": "integer", + "format": "int32", + "description": "The target number of blocks that this transaction should be confirmed\nby." + }, + "sat_per_vbyte": { + "type": "string", + "format": "uint64", + "description": "A manual fee rate set in sat/vbyte that should be used when crafting the\ntransaction." + }, + "sat_per_byte": { + "type": "string", + "format": "int64", + "description": "Deprecated, use sat_per_vbyte.\nA manual fee rate set in sat/vbyte that should be used when crafting the\ntransaction." + }, + "send_all": { + "type": "boolean", + "description": "If set, the amount field should be unset. It indicates lnd will send all\nwallet coins or all selected coins to the specified address." + }, + "label": { + "type": "string", + "description": "An optional label for the transaction, limited to 500 characters." + }, + "min_confs": { + "type": "integer", + "format": "int32", + "description": "The minimum number of confirmations each one of your outputs used for\nthe transaction must satisfy." + }, + "spend_unconfirmed": { + "type": "boolean", + "description": "Whether unconfirmed outputs should be used as inputs for the transaction." + }, + "coin_selection_strategy": { + "$ref": "#/definitions/lnrpcCoinSelectionStrategy", + "description": "The strategy to use for selecting coins." + }, + "outpoints": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/lnrpcOutPoint" + }, + "description": "A list of selected outpoints as inputs for the transaction." + } + } + }, + "lnrpcSendCoinsResponse": { + "type": "object", + "properties": { + "txid": { + "type": "string", + "title": "The transaction ID of the transaction" + } + } + }, "looprpcAbandonSwapResponse": { "type": "object" }, @@ -1616,6 +1759,10 @@ "type": "string", "format": "byte", "description": "The swap hash of the swap that this deposit is part of. This field is only\nset if the deposit is part of a loop-in swap." + }, + "static_address": { + "type": "string", + "description": "The static address that the deposit was sent to." } } }, @@ -2498,6 +2645,10 @@ "type": "string", "format": "byte", "description": "The client's public key for the 2-of-2 MuSig2 taproot static address." + }, + "send_coins_request": { + "$ref": "#/definitions/lnrpcSendCoinsRequest", + "description": "If set, loopd initiates a deposit by calling lnd's SendCoins API. If the\nrequest's addr field is empty, loopd creates and funds a new static\naddress. If addr is set, it must be an existing static address known to\nloopd." } } }, @@ -2512,6 +2663,10 @@ "type": "integer", "format": "int64", "description": "The CSV expiry of the static address." + }, + "send_coins_response": { + "$ref": "#/definitions/lnrpcSendCoinsResponse", + "description": "The response from lnd's SendCoins API, if a deposit was initiated." } } }, @@ -2599,6 +2754,119 @@ "type": "object", "description": "PublishSucceeded is returned by SweepHtlc if publishing was requested in\nSweepHtlcRequest and it succeeded." }, + "looprpcRecoverDepositRequest": { + "type": "object", + "properties": { + "txid": { + "type": "string", + "description": "The transaction ID that created the static-address deposit output." + }, + "vout": { + "type": "integer", + "format": "int64", + "description": "The output index in the transaction." + }, + "height_hint": { + "type": "integer", + "format": "int32", + "description": "The block height hint used when registering the confirmation notification." + }, + "pkscript_hex": { + "type": "string", + "description": "The expected static-address P2TR pkScript encoded as hex." + }, + "scan_limit": { + "type": "integer", + "format": "int64", + "description": "Optional highest child index to scan in each static-address key family. If\nunset, loopd uses its default recovery scan limit." + } + } + }, + "looprpcRecoverDepositResponse": { + "type": "object", + "properties": { + "outpoint": { + "type": "string", + "description": "The recovered deposit outpoint in the form txid:index." + }, + "value": { + "type": "string", + "format": "int64", + "description": "The recovered output value in satoshis." + }, + "confirmation_height": { + "type": "string", + "format": "int64", + "description": "The block height at which the deposit transaction confirmed." + }, + "client_key_family": { + "type": "integer", + "format": "int32", + "description": "The matched client key family." + }, + "client_key_index": { + "type": "integer", + "format": "int64", + "description": "The matched client key index." + }, + "static_address": { + "type": "string", + "description": "The static address that owns the recovered deposit." + }, + "recovered_address": { + "type": "boolean", + "description": "Whether a static-address row or wallet import was restored." + }, + "recovered_deposit": { + "type": "boolean", + "description": "Whether a deposit row was created or reactivated." + }, + "deposit_id": { + "type": "string", + "format": "byte", + "description": "The recovered deposit ID." + } + } + }, + "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..991ac26b2 100644 --- a/looprpc/client.yaml +++ b/looprpc/client.yaml @@ -33,6 +33,12 @@ http: get: "/v1/l402/tokens" additional_bindings: - get: "/v1/lsat/tokens" + - selector: looprpc.SwapClient.Recover + post: "/v1/recover" + body: "*" + - selector: looprpc.SwapClient.RecoverDeposit + post: "/v1/recover/deposit" + 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..dda85a416 100644 --- a/looprpc/client_grpc.pb.go +++ b/looprpc/client_grpc.pb.go @@ -76,6 +76,14 @@ 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: `recoverdeposit` + // RecoverDeposit verifies one static-address deposit on-chain and restores + // its local address/deposit state without relying on wallet reconciliation. + RecoverDeposit(ctx context.Context, in *RecoverDepositRequest, opts ...grpc.CallOption) (*RecoverDepositResponse, error) // loop: `getinfo` // GetInfo gets basic information about the loop daemon. GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) @@ -312,6 +320,24 @@ 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) RecoverDeposit(ctx context.Context, in *RecoverDepositRequest, opts ...grpc.CallOption) (*RecoverDepositResponse, error) { + out := new(RecoverDepositResponse) + err := c.cc.Invoke(ctx, "/looprpc.SwapClient/RecoverDeposit", 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 +562,14 @@ 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: `recoverdeposit` + // RecoverDeposit verifies one static-address deposit on-chain and restores + // its local address/deposit state without relying on wallet reconciliation. + RecoverDeposit(context.Context, *RecoverDepositRequest) (*RecoverDepositResponse, error) // loop: `getinfo` // GetInfo gets basic information about the loop daemon. GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) @@ -656,6 +690,12 @@ 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) RecoverDeposit(context.Context, *RecoverDepositRequest) (*RecoverDepositResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RecoverDeposit not implemented") +} func (UnimplementedSwapClientServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetInfo not implemented") } @@ -996,6 +1036,42 @@ 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_RecoverDeposit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RecoverDepositRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SwapClientServer).RecoverDeposit(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.SwapClient/RecoverDeposit", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SwapClientServer).RecoverDeposit(ctx, req.(*RecoverDepositRequest)) + } + 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 +1459,14 @@ var SwapClient_ServiceDesc = grpc.ServiceDesc{ MethodName: "FetchL402Token", Handler: _SwapClient_FetchL402Token_Handler, }, + { + MethodName: "Recover", + Handler: _SwapClient_Recover_Handler, + }, + { + MethodName: "RecoverDeposit", + Handler: _SwapClient_RecoverDeposit_Handler, + }, { MethodName: "GetInfo", Handler: _SwapClient_GetInfo_Handler, diff --git a/looprpc/perms.go b/looprpc/perms.go index d646f6671..6b74bd260 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -82,7 +82,7 @@ var RequiredPermissions = map[string][]bakery.Op{ }}, "/looprpc.SwapClient/NewStaticAddress": {{ Entity: "swap", - Action: "read", + Action: "execute", }, { Entity: "loop", Action: "in", @@ -151,6 +151,20 @@ var RequiredPermissions = map[string][]bakery.Op{ Entity: "auth", Action: "write", }}, + "/looprpc.SwapClient/Recover": {{ + Entity: "auth", + Action: "write", + }, { + Entity: "loop", + Action: "in", + }}, + "/looprpc.SwapClient/RecoverDeposit": {{ + Entity: "swap", + Action: "execute", + }, { + 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..3a9705ac0 100644 --- a/looprpc/swapclient.pb.json.go +++ b/looprpc/swapclient.pb.json.go @@ -413,6 +413,56 @@ 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.RecoverDeposit"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &RecoverDepositRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewSwapClientClient(conn) + resp, err := client.RecoverDeposit(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..d432b833d 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 @@ -64,6 +72,11 @@ type Config struct { // MinAliveConnTime is the minimum time that the connection to the // server needs to be alive before we consider it a successful. MinAliveConnTime time.Duration + + // PersistStaticLoopInRiskDecision durably records static loop-in + // confirmation-risk decisions before they are forwarded to subscribers. + PersistStaticLoopInRiskDecision func(context.Context, lntypes.Hash, + bool) error } // Manager is a manager for notifications that the swap server sends to the @@ -76,6 +89,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 +107,112 @@ 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 + enqueue func(any) +} + +func newNotificationQueue[T any](ctx context.Context, + recvChan chan T) func(any) { + + type queue struct { + sync.Mutex + + pending []T + notify chan struct{} + } + + q := &queue{ + notify: make(chan struct{}, 1), + } + + go func() { + defer func() { + if recover() != nil { + log.Debugf("subscriber channel closed before " + + "notification delivery") + } + }() + + for { + q.Lock() + if len(q.pending) == 0 { + q.Unlock() + + select { + case <-q.notify: + continue + + case <-ctx.Done(): + return + } + } + + ntfn := q.pending[0] + q.pending = q.pending[1:] + q.Unlock() + + select { + case recvChan <- ntfn: + case <-ctx.Done(): + return + } + } + }() + + return func(ntfn any) { + typedNtfn, ok := ntfn.(T) + if !ok { + log.Warnf("unexpected notification type %T", ntfn) + return + } + + q.Lock() + q.pending = append(q.pending, typedNtfn) + q.Unlock() + + select { + case q.notify <- struct{}{}: + default: + } + } +} + +func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) { + if sub.enqueue != nil { + sub.enqueue(ntfn) + return + } + + select { + case recvChan <- ntfn: + case <-sub.subCtx.Done(): + } +} + +func dropNotification[T any](sub subscriber, recvChan chan T, ntfn T, + description string) { + + select { + case recvChan <- ntfn: + case <-sub.subCtx.Done(): + default: + log.Debugf("Dropping %s notification for slow subscriber", + description) + } } // SubscribeReservations subscribes to the reservation notifications. @@ -128,6 +247,7 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context, sub := subscriber{ subCtx: ctx, recvChan: notifChan, + enqueue: newNotificationQueue(ctx, notifChan), } m.addSubscriber(NotificationTypeStaticLoopInSweepRequest, sub) @@ -143,6 +263,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 { @@ -153,6 +347,7 @@ func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context, sub := subscriber{ subCtx: ctx, recvChan: notifChan, + enqueue: newNotificationQueue(ctx, notifChan), } m.addSubscriber(NotificationTypeUnfinishedSwap, sub) @@ -277,7 +472,7 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error { notification, err := notifStream.Recv() if err == nil && notification != nil { log.Tracef("Received notification: %v", notification) - m.handleNotification(notification) + m.handleNotification(ctx, notification) continue } @@ -287,9 +482,67 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error { } } +func staticLoopInRiskDecisionName(accepted bool) string { + if accepted { + return "accepted" + } + + return "rejected" +} + +func (m *Manager) handleStaticLoopInRiskDecision(ctx context.Context, + swapHashBytes []byte, accepted bool, notifType NotificationType, + cacheDecision func(lntypes.Hash), notifySubscriber func(subscriber)) { + + decision := staticLoopInRiskDecisionName(accepted) + + var ( + swapHash lntypes.Hash + hasSwapHash bool + ) + if swapHashBytes != nil { + hash, err := lntypes.MakeHash(swapHashBytes) + if err != nil { + log.Warnf("Received invalid static loop in risk "+ + "%s notification: %v", decision, err) + } else { + swapHash = hash + hasSwapHash = true + } + } + + if hasSwapHash && m.cfg.PersistStaticLoopInRiskDecision != nil { + err := m.cfg.PersistStaticLoopInRiskDecision( + ctx, swapHash, accepted, + ) + if err != nil { + log.Errorf("Unable to persist static loop in risk "+ + "%s notification: %v", decision, err) + return + } + } + + m.Lock() + defer m.Unlock() + + if hasSwapHash { + cacheDecision(swapHash) + } + + for _, sub := range m.subscribers[notifType] { + if !hasSwapHash || sub.swapHash == nil || + *sub.swapHash != swapHash { + + continue + } + + notifySubscriber(sub) + } +} + // handleNotification handles an incoming notification from the server, // forwarding it to the appropriate subscribers. -func (m *Manager) handleNotification(ntfn *swapserverrpc. +func (m *Manager) handleNotification(ctx context.Context, ntfn *swapserverrpc. SubscribeNotificationsResponse) { switch ntfn.Notification.(type) { @@ -303,7 +556,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,9 +575,63 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc. recvChan := sub.recvChan.(chan *swapserverrpc. ServerStaticLoopInSweepNotification) - recvChan <- staticLoopInSweepRequestNtfn + queueNotification(sub, recvChan, staticLoopInSweepRequestNtfn) } + 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() + var swapHashBytes []byte + if riskAcceptedNtfn != nil { + swapHashBytes = riskAcceptedNtfn.SwapHash + } + + m.handleStaticLoopInRiskDecision( + ctx, swapHashBytes, true, + NotificationTypeStaticLoopInRiskAccepted, + func(swapHash lntypes.Hash) { + m.staticLoopInRiskAccepted[swapHash] = + riskAcceptedNtfn + delete(m.staticLoopInRiskRejected, swapHash) + }, + func(sub subscriber) { + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification) + dropNotification( + sub, recvChan, riskAcceptedNtfn, + "static loop in risk accepted", + ) + }, + ) + + 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() + var swapHashBytes []byte + if riskRejectedNtfn != nil { + swapHashBytes = riskRejectedNtfn.SwapHash + } + + m.handleStaticLoopInRiskDecision( + ctx, swapHashBytes, false, + NotificationTypeStaticLoopInRiskRejected, + func(swapHash lntypes.Hash) { + m.staticLoopInRiskRejected[swapHash] = + riskRejectedNtfn + delete(m.staticLoopInRiskAccepted, swapHash) + }, + func(sub subscriber) { + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification) + dropNotification( + sub, recvChan, riskRejectedNtfn, + "static loop in risk rejected", + ) + }, + ) + case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll // We'll forward the unfinished swap notification to all // subscribers. @@ -330,7 +643,7 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc. recvChan := sub.recvChan.(chan *swapserverrpc. ServerUnfinishedSwapNotification) - recvChan <- unfinishedSwapNtfn + queueNotification(sub, recvChan, unfinishedSwapNtfn) } default: @@ -353,7 +666,7 @@ func (m *Manager) removeSubscriber(notifType NotificationType, sub subscriber) { subs := m.subscribers[notifType] newSubs := make([]subscriber, 0, len(subs)) for _, s := range subs { - if s != sub { + if s.recvChan != sub.recvChan { newSubs = append(newSubs, s) } } diff --git a/notifications/manager_test.go b/notifications/manager_test.go index 9a06503e8..b09e15df0 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,489 @@ 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 staticLoopInSweepNotification( + swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse { + + return &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInSweep{ + StaticLoopInSweep: &swapserverrpc. + ServerStaticLoopInSweepNotification{ + 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(t.Context(), 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(t.Context(), 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(t.Context(), firstNotif) + + received := <-fastChan + require.Equal(t, testReservationId, received.ReservationId) + + secondNotif := getTestNotification(testReservationId2) + done := make(chan struct{}) + go func() { + mgr.handleNotification(t.Context(), 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() + + assertQueuedSwapHashNotifications( + t, + func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc. + ServerUnfinishedSwapNotification { + + return mgr.SubscribeUnfinishedSwaps(ctx) + }, + unfinishedSwapNotification, + func(ntfn *swapserverrpc.ServerUnfinishedSwapNotification) []byte { + return ntfn.SwapHash + }, + lntypes.Hash{0x02, 0x03}, lntypes.Hash{0x04, 0x05}, + "did not receive first unfinished swap notification", + "second unfinished swap notification was dropped", + ) +} + +// TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber verifies +// that a full static-loop-in sweep subscriber channel does not block the global +// notification receive loop. +func TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber( + t *testing.T) { + + t.Parallel() + + assertQueuedSwapHashNotifications( + t, + func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc. + ServerStaticLoopInSweepNotification { + + return mgr.SubscribeStaticLoopInSweepRequests(ctx) + }, + staticLoopInSweepNotification, + func(ntfn *swapserverrpc.ServerStaticLoopInSweepNotification) []byte { + return ntfn.SwapHash + }, + lntypes.Hash{0x12, 0x13}, lntypes.Hash{0x14, 0x15}, + "did not receive first sweep notification", + "second sweep notification was not queued", + ) +} + +func assertQueuedSwapHashNotifications[T any](t *testing.T, + subscribe func(*Manager, context.Context) <-chan T, + notification func(lntypes.Hash) *swapserverrpc. + SubscribeNotificationsResponse, + swapHash func(T) []byte, swapHashA, swapHashB lntypes.Hash, + firstFailureMsg, secondFailureMsg string) { + + t.Helper() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChan := subscribe(mgr, subCtx) + + mgr.handleNotification(t.Context(), notification(swapHashA)) + + done := make(chan struct{}) + go func() { + mgr.handleNotification(t.Context(), notification(swapHashB)) + close(done) + }() + + require.Eventually(t, func() bool { + select { + case <-done: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond) + + select { + case received := <-subChan: + require.Equal(t, swapHashA[:], swapHash(received)) + + case <-time.After(time.Second): + t.Fatal(firstFailureMsg) + } + + select { + case received := <-subChan: + require.Equal(t, swapHashB[:], swapHash(received)) + + case <-time.After(time.Second): + t.Fatal(secondFailureMsg) + } +} + +// 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( + t.Context(), + &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_StaticLoopInRiskDecisionPersists verifies that risk decisions are +// handed to the durable callback before they are treated as delivered. +func TestManager_StaticLoopInRiskDecisionPersists(t *testing.T) { + t.Parallel() + + type persistedDecision struct { + swapHash lntypes.Hash + accepted bool + } + + persisted := make(chan persistedDecision, 2) + mgr := NewManager(&Config{ + PersistStaticLoopInRiskDecision: func(_ context.Context, + swapHash lntypes.Hash, accepted bool) error { + + persisted <- persistedDecision{ + swapHash: swapHash, + accepted: accepted, + } + + return nil + }, + }) + + acceptedHash := lntypes.Hash{0x16, 0x17} + rejectedHash := lntypes.Hash{0x18, 0x19} + + mgr.handleNotification( + t.Context(), staticLoopInRiskAcceptedNotification(acceptedHash), + ) + mgr.handleNotification( + t.Context(), staticLoopInRiskRejectedNotification(rejectedHash), + ) + + select { + case decision := <-persisted: + require.Equal(t, acceptedHash, decision.swapHash) + require.True(t, decision.accepted) + + case <-time.After(time.Second): + t.Fatal("accepted risk decision was not persisted") + } + + select { + case decision := <-persisted: + require.Equal(t, rejectedHash, decision.swapHash) + require.False(t, decision.accepted) + + case <-time.After(time.Second): + t.Fatal("rejected risk decision was not persisted") + } +} + +// 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( + t.Context(), + &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( + t.Context(), + &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( + t.Context(), + &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..d13121e19 --- /dev/null +++ b/recovery/README.md @@ -0,0 +1,348 @@ +# 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 multi-address scan floor is independent of 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, multi-address receive/change key +families, Bitcoin network, and multi-address first height are stable for the +L402 generation. Multi-address restore combines those fields with lnd-derived +client keys and matches the resulting scripts against wallet-visible UTXOs. + +The legacy concrete address predates the receive/change branches and is restored +as one explicit row. The backup therefore also stores that row's client pubkey, +legacy client key family, and legacy first height, which let restore find the +matching local wallet child and import the concrete address from the right +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. scan the multi-address receive/change branches against wallet-visible UTXOs + and recreate matching concrete address rows +9. if an address restore phase fails after token files were written, remove only + the token files written by this restore attempt +10. 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 + +Multi-address scanning uses the immutable address-space fields in the backup to +derive receive/change candidates without requiring a backed-up last-issued child +index. + +## Multi-Address Generation + +The 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 `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 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, restore regenerates candidate receive and +change children from the backed-up branch fields and matches their scripts +against lnd's wallet-visible UTXOs. The scan uses a rolling gap limit: each +matched child resets the unused counter, and restore stops only after a full +gap of consecutive unused children. The default gap is restore policy, not +immutable backup data, so restore does not depend on a mutable "last issued +child index" snapshot. + +The multi-address branch scan takes one unfiltered `ListUnspent` snapshot from +lnd and matches all derived candidates against that in-memory script set. +Deposit reconciliation runs after matching and performs its own `ListUnspent` +pass through the address manager so it can use the newly active address rows and +confirmation metadata. + +## 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 rebuilds current wallet-visible static-address +state, not the full historical database. + +Some practical consequences follow from that: + +- restoring an older immutable backup is best done into a fresh Loop data + directory +- the legacy concrete static address is restored directly +- multi-address receive/change rows are recreated for wallet-visible unspent + outputs discovered by the rolling branch scan +- conflicting local `l402.token` contents or different existing address rows + cause restore to fail rather than overwrite local state +- historical deposit state is rebuilt best-effort from 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 root fields for multi-address restore +- multi-address branch scanning against wallet-visible UTXOs +- post-restore deposit reconciliation orchestration + +This package does not own: + +- CLI command handling +- gRPC transport +- the static-address server protocol +- `loopd` startup wiring diff --git a/recovery/service.go b/recovery/service.go new file mode 100644 index 000000000..334d39763 --- /dev/null +++ b/recovery/service.go @@ -0,0 +1,1582 @@ +package recovery + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/aperture/l402" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + staticaddrscript "github.com/lightninglabs/loop/staticaddr/script" + staticaddrversion "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/input" + "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" +) + +// DefaultMultiAddressScanGap is the default number of consecutive unused +// multi-address children to scan before restore stops. It is intentionally a +// package-level default so it can be wired to configuration later. +var DefaultMultiAddressScanGap = 20 + +// 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) + + // RecoverDeposit verifies one on-chain static-address output and restores + // the corresponding local deposit/address state. + RecoverDeposit(context.Context, + *deposit.RecoveryRequest) (*deposit.RecoveryResult, error) +} + +// RecoverResult describes the outcome of a restore attempt. +type RecoverResult struct { + BackupFile string + StaticAddress string + RestoredStaticAddress bool + RestoredL402 bool + NumDepositsFound int + DepositReconciliationError string +} + +// RecoverDepositRequest describes one static-address deposit to recover from +// on-chain data supplied by the caller. +type RecoverDepositRequest struct { + TxID string + VOut uint32 + HeightHint int32 + PkScriptHex string + ScanLimit uint32 +} + +// RecoverDepositResult describes the recovered deposit and the static-address +// child that matched the supplied pkScript. +type RecoverDepositResult struct { + OutPoint string + Value btcutil.Amount + ConfirmationHeight int64 + ClientKeyFamily int32 + ClientKeyIndex uint32 + StaticAddress string + RecoveredAddress bool + RecoveredDeposit bool + DepositID []byte +} + +// 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 + multiAddressScanGap int +} + +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, + multiAddressScanGap: DefaultMultiAddressScanGap, + } +} + +// 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 + + restored, err = s.restoreMultiAddressBranches( + ctx, payload.StaticAddress, restoreParams.ServerPubkey, + ) + if err != nil { + rollbackErr := cleanupRestoredTokenFiles( + tokenRestore.writtenPaths, + ) + if rollbackErr != nil { + return nil, fmt.Errorf("unable to restore multi-"+ + "address static addresses: %w (also failed "+ + "to roll back restored token files: %v)", + err, rollbackErr) + } + + return nil, err + } + + result.RestoredStaticAddress = 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 +} + +// RecoverDeposit restores one static-address deposit from caller-supplied +// on-chain coordinates. The recovery package only validates the RPC-shaped +// request and delegates chain/address/deposit work to the deposit manager. +func (s *Service) RecoverDeposit(ctx context.Context, + req *RecoverDepositRequest) (*RecoverDepositResult, error) { + + if s.depositManager == nil { + return nil, fmt.Errorf("deposit recovery is unavailable") + } + + depositReq, err := parseRecoverDepositRequest(req) + if err != nil { + return nil, err + } + + result, err := s.depositManager.RecoverDeposit(ctx, depositReq) + if err != nil { + return nil, err + } + + if result.AddressParams == nil { + return nil, fmt.Errorf("recovered deposit missing address " + + "parameters") + } + + return &RecoverDepositResult{ + OutPoint: result.OutPoint.String(), + Value: result.Value, + ConfirmationHeight: result.ConfirmationHeight, + ClientKeyFamily: int32( + result.AddressParams.KeyLocator.Family, + ), + ClientKeyIndex: result.AddressParams.KeyLocator.Index, + StaticAddress: result.StaticAddress, + RecoveredAddress: result.RecoveredAddress, + RecoveredDeposit: result.RecoveredDeposit, + DepositID: result.DepositID[:], + }, nil +} + +func parseRecoverDepositRequest(req *RecoverDepositRequest) ( + *deposit.RecoveryRequest, error) { + + if req == nil { + return nil, fmt.Errorf("missing recover deposit request") + } + if req.TxID == "" { + return nil, fmt.Errorf("missing txid") + } + if req.HeightHint <= 0 { + return nil, fmt.Errorf("height_hint must be positive") + } + if req.PkScriptHex == "" { + return nil, fmt.Errorf("missing pkscript_hex") + } + + txid, err := chainhash.NewHashFromStr(req.TxID) + if err != nil { + return nil, fmt.Errorf("invalid txid: %w", err) + } + + pkScript, err := hex.DecodeString( + strings.TrimPrefix(req.PkScriptHex, "0x"), + ) + if err != nil { + return nil, fmt.Errorf("invalid pkscript_hex: %w", err) + } + if !isP2TRPkScript(pkScript) { + return nil, fmt.Errorf("pkscript_hex must encode a P2TR " + + "pkScript") + } + + return &deposit.RecoveryRequest{ + TxID: *txid, + VOut: req.VOut, + HeightHint: req.HeightHint, + PkScript: pkScript, + ScanLimit: req.ScanLimit, + }, nil +} + +func isP2TRPkScript(pkScript []byte) bool { + return len(pkScript) == 34 && + pkScript[0] == txscript.OP_1 && + pkScript[1] == 32 +} + +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 + } + + // Build the full concrete row, including pkScript, through the same helper + // used by multi-address recovery so both paths reconstruct scripts in one + // place. RestoreAddress still derives and verifies the script before it + // writes or imports anything. + params, err := newRecoveredStaticAddress( + backup, serverPubKey, &keychain.KeyDescriptor{ + KeyLocator: locator, + PubKey: clientPubKey, + }, backup.LegacyFirstHeight, + ) + if err != nil { + return nil, err + } + + return params, 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 +} + +// restoreMultiAddressBranches scans all backed-up multi-address key families +// against a single cached ListUnspent view. The scan is branch-local and uses a +// rolling gap: every matched child resets the unused-child counter, so sparse +// deposits remain recoverable as long as the distance between hits is below the +// configured scan gap. +func (s *Service) restoreMultiAddressBranches(ctx context.Context, + backup *staticAddressBackup, serverPubKey *btcec.PublicKey) (bool, error) { + + if backup == nil { + return false, nil + } + if serverPubKey == nil { + return false, fmt.Errorf("missing static address server pubkey") + } + if s.multiAddressScanGap <= 0 { + return false, fmt.Errorf("invalid multi-address scan gap %d", + s.multiAddressScanGap) + } + + families := multiAddressFamilies(backup) + if len(families) == 0 { + return false, nil + } + + walletScripts, err := s.walletUnspentScriptSet(ctx) + if err != nil { + return false, err + } + if len(walletScripts) == 0 { + return false, nil + } + + var restored bool + for _, family := range families { + branchRestored, err := s.restoreMultiAddressBranch( + ctx, backup, serverPubKey, family, walletScripts, + ) + if err != nil { + return false, err + } + + restored = restored || branchRestored + } + + return restored, nil +} + +// multiAddressFamilies returns the non-zero multi-address branch families from +// the backup while preserving their order and suppressing duplicates. +func multiAddressFamilies(backup *staticAddressBackup) []int32 { + var families []int32 + seen := make(map[int32]struct{}, 2) + for _, family := range []int32{ + backup.MainKeyFamily, + backup.ChangeKeyFamily, + } { + if family == 0 { + continue + } + if _, ok := seen[family]; ok { + continue + } + + seen[family] = struct{}{} + families = append(families, family) + } + + return families +} + +// walletUnspentScriptSet returns the pkScripts currently visible in lnd's +// wallet. This is intentionally called once per restore before branch scanning; +// scanning then derives candidates in memory and checks them against this set. +// Deposit reconciliation runs afterwards and performs its own ListUnspent pass +// because it needs the active address-manager view and confirmation metadata. +func (s *Service) walletUnspentScriptSet(ctx context.Context) ( + map[string]struct{}, error) { + + // List all wallet-visible UTXOs. This follows the legacy recovery model: + // recovery reconstructs scripts and matches them against lnd's current + // wallet view. + utxos, err := s.walletKit.ListUnspent(ctx, 0, 0) + if err != nil { + return nil, fmt.Errorf("unable to list unspent outputs for "+ + "multi-address recovery: %w", err) + } + + scripts := make(map[string]struct{}, len(utxos)) + for _, utxo := range utxos { + scripts[string(utxo.PkScript)] = struct{}{} + } + + return scripts, nil +} + +// restoreMultiAddressBranch derives concrete children for one key family until +// it observes multiAddressScanGap consecutive misses. Each hit is restored +// through the address manager so the concrete row is persisted and imported +// before deposit reconciliation discovers matching UTXOs. +func (s *Service) restoreMultiAddressBranch(ctx context.Context, + backup *staticAddressBackup, serverPubKey *btcec.PublicKey, + keyFamily int32, walletScripts map[string]struct{}) (bool, error) { + + var restored bool + unused := 0 + for idx := uint32(0); unused < s.multiAddressScanGap; idx++ { + params, err := s.deriveMultiAddress( + ctx, backup, serverPubKey, keyFamily, idx, + ) + if err != nil { + return false, err + } + + if _, ok := walletScripts[string(params.PkScript)]; !ok { + unused++ + continue + } + + _, changed, err := s.staticAddressManager.RestoreAddress( + ctx, params, + ) + if err != nil { + return false, err + } + + restored = restored || changed + unused = 0 + } + + return restored, nil +} + +// deriveMultiAddress reconstructs one concrete receive/change child from the +// backed-up generation root and a child locator. +func (s *Service) deriveMultiAddress(ctx context.Context, + backup *staticAddressBackup, serverPubKey *btcec.PublicKey, + keyFamily int32, keyIndex uint32) (*address.Parameters, error) { + + locator := keychain.KeyLocator{ + Family: keychain.KeyFamily(keyFamily), + Index: keyIndex, + } + + clientKey, err := s.walletKit.DeriveKey(ctx, &locator) + if err != nil { + return nil, fmt.Errorf("unable to derive multi-address child "+ + "%d:%d: %w", keyFamily, keyIndex, err) + } + + return newRecoveredStaticAddress( + backup, serverPubKey, clientKey, backup.MultiAddressFirstHeight, + ) +} + +// newRecoveredStaticAddress builds the concrete static-address parameters and +// pkScript for a recovered child key. Legacy recovery uses the same helper as +// multi-address scanning so both paths reconstruct scripts identically. +func newRecoveredStaticAddress(backup *staticAddressBackup, + serverPubKey *btcec.PublicKey, clientKey *keychain.KeyDescriptor, + initiationHeight int32) (*address.Parameters, error) { + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, int64(backup.Expiry), + clientKey.PubKey, serverPubKey, + ) + if err != nil { + return nil, err + } + + pkScript, err := staticAddress.StaticAddressScript() + if err != nil { + return nil, err + } + + return &address.Parameters{ + ClientPubkey: clientKey.PubKey, + ServerPubkey: serverPubKey, + Expiry: backup.Expiry, + PkScript: pkScript, + KeyLocator: clientKey.KeyLocator, + ProtocolVersion: staticaddrversion.AddressProtocolVersion( + backup.ProtocolVersion, + ), + InitiationHeight: initiationHeight, + }, 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..146c58ace --- /dev/null +++ b/recovery/service_test.go @@ -0,0 +1,2554 @@ +package recovery + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "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/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/aperture/l402" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + 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/lnwallet" + "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) +} + +// TestRestoreStaticAddressDiscoversMultiAddressDeposits verifies that restore +// scans receive and change branches with a rolling gap. Hits separated by one +// less than the gap must all be recovered, while restore stops after a full +// trailing gap. +func TestRestoreStaticAddressDiscoversMultiAddressDeposits(t *testing.T) { + 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, + currentHeight: 654, + } + 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) + + gap := uint32(DefaultMultiAddressScanGap) + require.Greater(t, gap, uint32(1)) + + receiveIndexes := []uint32{gap - 1, 2*gap - 1, 3*gap - 1} + changeIndexes := []uint32{gap - 1} + restoreWallet := &multiAddressRecoveryWalletKit{} + + var utxos []*lnwallet.Utxo + for _, index := range receiveIndexes { + utxos = append(utxos, multiAddressUtxo( + t, restoreWallet, swap.StaticMultiAddressKeyFamily, index, + addrParams, len(utxos), + )) + } + for _, index := range changeIndexes { + utxos = append(utxos, multiAddressUtxo( + t, restoreWallet, swap.StaticAddressChangeKeyFamily, index, + addrParams, len(utxos), + )) + } + + // This output is beyond a full unused gap after the last receive hit, so + // it documents the stopping condition and must not be restored. + unreachableReceiveIndex := 4 * gap + utxos = append(utxos, multiAddressUtxo( + t, restoreWallet, swap.StaticMultiAddressKeyFamily, + unreachableReceiveIndex, addrParams, len(utxos), + )) + restoreWallet.utxos = utxos + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, restoreWallet, + destStaticMgr, nil, + ) + + result, err := destSvc.Restore(ctx, backupFile) + require.NoError(t, err) + require.True(t, result.RestoredStaticAddress) + require.Equal(t, 1, restoreWallet.listUnspentCalls) + + restoredLocators := make(map[keychain.KeyLocator]struct{}) + for _, params := range destStaticMgr.restoreCalls { + restoredLocators[params.KeyLocator] = struct{}{} + } + + require.Contains(t, restoredLocators, addrParams.KeyLocator) + for _, index := range receiveIndexes { + require.Contains(t, restoredLocators, keychain.KeyLocator{ + Family: keychain.KeyFamily( + swap.StaticMultiAddressKeyFamily, + ), + Index: index, + }) + } + for _, index := range changeIndexes { + require.Contains(t, restoredLocators, keychain.KeyLocator{ + Family: keychain.KeyFamily( + swap.StaticAddressChangeKeyFamily, + ), + Index: index, + }) + } + require.NotContains(t, restoredLocators, keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticMultiAddressKeyFamily), + Index: unreachableReceiveIndex, + }) + require.Len(t, destStaticMgr.restoreCalls, 1+len(receiveIndexes)+ + len(changeIndexes)) +} + +// 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) +} + +func TestRecoverDepositParsesAndDelegates(t *testing.T) { + ctx := context.Background() + txid := chainhash.Hash{1, 2, 3} + depositID := deposit.ID{9, 8, 7} + depositMgr := &mockDepositManager{ + recoverResult: &deposit.RecoveryResult{ + OutPoint: wire.OutPoint{ + Hash: txid, + Index: 2, + }, + Value: 50_000, + ConfirmationHeight: 123, + AddressParams: &address.Parameters{ + KeyLocator: keychain.KeyLocator{ + Family: 42061, + Index: 7, + }, + }, + StaticAddress: "bc1pstatic", + RecoveredAddress: true, + RecoveredDeposit: true, + DepositID: depositID, + }, + } + service := &Service{depositManager: depositMgr} + + result, err := service.RecoverDeposit(ctx, &RecoverDepositRequest{ + TxID: txid.String(), + VOut: 2, + HeightHint: 100, + PkScriptHex: "0x" + hex.EncodeToString(testP2TRPkScript()), + ScanLimit: 5, + }) + require.NoError(t, err) + require.NotNil(t, depositMgr.recoverReq) + require.Equal(t, txid, depositMgr.recoverReq.TxID) + require.EqualValues(t, 2, depositMgr.recoverReq.VOut) + require.EqualValues(t, 100, depositMgr.recoverReq.HeightHint) + require.Equal(t, testP2TRPkScript(), depositMgr.recoverReq.PkScript) + require.EqualValues(t, 5, depositMgr.recoverReq.ScanLimit) + + require.Equal(t, txid.String()+":2", result.OutPoint) + require.EqualValues(t, 50_000, result.Value) + require.EqualValues(t, 123, result.ConfirmationHeight) + require.EqualValues(t, 42061, result.ClientKeyFamily) + require.EqualValues(t, 7, result.ClientKeyIndex) + require.Equal(t, "bc1pstatic", result.StaticAddress) + require.True(t, result.RecoveredAddress) + require.True(t, result.RecoveredDeposit) + require.Equal(t, depositID[:], result.DepositID) +} + +func TestRecoverDepositRejectsInvalidP2TRScript(t *testing.T) { + depositMgr := &mockDepositManager{} + service := &Service{depositManager: depositMgr} + + _, err := service.RecoverDeposit( + context.Background(), &RecoverDepositRequest{ + TxID: chainhash.Hash{1}.String(), + VOut: 0, + HeightHint: 1, + PkScriptHex: "0014", + }, + ) + require.ErrorContains(t, err, "P2TR") + require.Nil(t, depositMgr.recoverReq) +} + +func testP2TRPkScript() []byte { + pkScript := make([]byte, 34) + pkScript[0] = 0x51 + pkScript[1] = 0x20 + for i := 2; i < len(pkScript); i++ { + pkScript[i] = byte(i) + } + + return pkScript +} + +// 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 + recoverReq *deposit.RecoveryRequest + recoverResult *deposit.RecoveryResult + recoverErr error +} + +func (m *mockDepositManager) ReconcileDeposits(context.Context) (int, error) { + m.calls++ + return m.depositsFound, m.err +} + +func (m *mockDepositManager) RecoverDeposit(_ context.Context, + req *deposit.RecoveryRequest) (*deposit.RecoveryResult, error) { + + m.recoverReq = req + if m.recoverErr != nil { + return nil, m.recoverErr + } + + return m.recoverResult, nil +} + +type multiAddressRecoveryWalletKit struct { + lndclient.WalletKitClient + + utxos []*lnwallet.Utxo + listUnspentCalls int +} + +func (w *multiAddressRecoveryWalletKit) ListUnspent(context.Context, int32, + int32, ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) { + + w.listUnspentCalls++ + + return w.utxos, nil +} + +func (w *multiAddressRecoveryWalletKit) DeriveKey(_ context.Context, + locator *keychain.KeyLocator) (*keychain.KeyDescriptor, error) { + + if locator == nil { + return nil, fmt.Errorf("missing key locator") + } + + seed := int32(locator.Index) + switch int32(locator.Family) { + case swap.StaticAddressKeyFamily: + _, pubKey := testutils.CreateKey(seed) + + return &keychain.KeyDescriptor{ + KeyLocator: *locator, + PubKey: pubKey, + }, nil + + case swap.StaticMultiAddressKeyFamily: + seed += 10_000 + + case swap.StaticAddressChangeKeyFamily: + seed += 20_000 + + default: + seed += int32(locator.Family) * 10_000 + } + + _, pubKey := deterministicTestKey(seed) + + return &keychain.KeyDescriptor{ + KeyLocator: *locator, + PubKey: pubKey, + }, nil +} + +func deterministicTestKey(seed int32) (*btcec.PrivateKey, *btcec.PublicKey) { + var key [32]byte + binary.BigEndian.PutUint32(key[28:], uint32(seed)+1) + + return btcec.PrivKeyFromBytes(key[:]) +} + +func multiAddressUtxo(t *testing.T, walletKit lndclient.WalletKitClient, + keyFamily int32, keyIndex uint32, addrParams *address.Parameters, + utxoIndex int) *lnwallet.Utxo { + + t.Helper() + + keyDesc, err := walletKit.DeriveKey( + context.Background(), &keychain.KeyLocator{ + Family: keychain.KeyFamily(keyFamily), + Index: keyIndex, + }, + ) + require.NoError(t, err) + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, int64(addrParams.Expiry), + keyDesc.PubKey, addrParams.ServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + var hash chainhash.Hash + hash[0] = byte(utxoIndex + 1) + + return &lnwallet.Utxo{ + OutPoint: wire.OutPoint{ + Hash: hash, + Index: uint32(utxoIndex), + }, + Value: btcutil.Amount(1000 + utxoIndex), + PkScript: pkScript, + } +} + +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/interface.go b/staticaddr/address/interface.go index 6f626b00b..444bbac75 100644 --- a/staticaddr/address/interface.go +++ b/staticaddr/address/interface.go @@ -4,7 +4,12 @@ import ( "context" "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/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" ) @@ -15,14 +20,27 @@ type Store interface { // into the store. CreateStaticAddress(ctx context.Context, addrParams *Parameters) error + // GetStaticAddressID retrieves the static address row ID for the + // address script. + GetStaticAddressID(ctx context.Context, pkScript []byte) (int32, error) + // GetAllStaticAddresses retrieves all static addresses from the store. GetAllStaticAddresses(ctx context.Context) ([]*Parameters, error) + + // GetLegacyParameters retrieves the first static address created for the + // L402. This is the immutable legacy/root address that anchors existing + // single-address deposits. + GetLegacyParameters(ctx context.Context) (*Parameters, error) } // Parameters holds all the necessary information for the 2-of-2 multisig // address. type Parameters struct { + // ID is the database primary key of the static address row. A zero value + // means the parameters have not been persisted yet. + ID int32 + // ClientPubkey is the client's pubkey for the static address. It is // used for the 2-of-2 funding output as well as for the client's // timeout path. @@ -48,3 +66,21 @@ type Parameters struct { // InitiationHeight is the height at which the address was initiated. InitiationHeight int32 } + +// TaprootAddress returns the bech32m taproot address for these static address +// parameters on the target network. +func (p *Parameters) TaprootAddress(network *chaincfg.Params) ( + *btcutil.AddressTaproot, error) { + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(p.Expiry), + p.ClientPubkey, p.ServerPubkey, + ) + if err != nil { + return nil, err + } + + return btcutil.NewAddressTaproot( + schnorr.SerializePubKey(staticAddress.TaprootKey), network, + ) +} diff --git a/staticaddr/address/manager.go b/staticaddr/address/manager.go index 3382210d2..b4601fda8 100644 --- a/staticaddr/address/manager.go +++ b/staticaddr/address/manager.go @@ -3,7 +3,10 @@ package address import ( "bytes" "context" + "database/sql" + "errors" "fmt" + "strings" "sync" "sync/atomic" @@ -29,6 +32,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 @@ -62,6 +71,12 @@ type Manager struct { cfg *ManagerConfig currentHeight atomic.Int32 + + // activeStaticAddresses is the runtime index used to match wallet UTXOs + // to locally known static address parameters. The DB remains the + // durable source of truth; this map is rebuilt from the DB on startup + // and updated after successful address issuance or recovery. + activeStaticAddresses map[string]*Parameters } // NewManager creates a new address manager. @@ -72,13 +87,21 @@ func NewManager(cfg *ManagerConfig, currentHeight int32) (*Manager, error) { } m := &Manager{ - cfg: cfg, + cfg: cfg, + activeStaticAddresses: make(map[string]*Parameters), } m.currentHeight.Store(currentHeight) 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 := @@ -88,6 +111,11 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + err = m.restoreActiveAddresses(ctx) + if err != nil { + return err + } + // Communicate to the caller that the address manager has completed its // initialization. close(initChan) @@ -107,54 +135,123 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { } } -// NewAddress creates a new static address with the server or returns an -// existing one. +// restoreActiveAddresses rebuilds the runtime address map from the durable DB +// state and re-imports all scripts into lnd. Importing is intentionally +// idempotent so restart and recovery paths repair missing wallet watches before +// deposit discovery starts. +func (m *Manager) restoreActiveAddresses(ctx context.Context) error { + params, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + return err + } + + active := make(map[string]*Parameters, len(params)) + for _, param := range params { + staticAddress, err := staticAddressFromParams(param) + if err != nil { + return err + } + + _, err = m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return err + } + + active[string(param.PkScript)] = param + } + + m.Lock() + m.activeStaticAddresses = active + m.Unlock() + + return nil +} + +// NewAddress creates the next externally visible receive static address. +// +// The first call also makes sure the legacy/root static address seed exists, +// because receive and change addresses are derived from the server pubkey and +// expiry returned for that seed. func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, int64, error) { - // If there's already a static address in the database, we can return - // it. - m.Lock() - addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) + params, err := m.NewReceiveAddress(ctx) if err != nil { - m.Unlock() + return nil, 0, err + } + address, err := params.TaprootAddress(m.cfg.ChainParams) + if err != nil { return nil, 0, err } - if len(addresses) > 0 { - clientPubKey := addresses[0].ClientPubkey - serverPubKey := addresses[0].ServerPubkey - expiry := int64(addresses[0].Expiry) - defer m.Unlock() + return address, int64(params.Expiry), nil +} - address, err := m.GetTaprootAddress( - clientPubKey, serverPubKey, expiry, - ) - if err != nil { - return nil, 0, err +// EnsureStaticAddressSeed loads or creates the legacy/root static address +// parameters. The root address is the only address that requires a Nautilus +// ServerNewAddress call; all receive/change addresses derive client keys +// locally and reuse this server pubkey/expiry seed. +func (m *Manager) EnsureStaticAddressSeed(ctx context.Context) (*Parameters, + error) { + + m.Lock() + seed := m.legacyParameters() + m.Unlock() + if seed != nil { + return seed, nil + } + + m.Lock() + defer m.Unlock() + + // Another caller may have created the seed while we were waiting for the + // issuance lock. + seed = m.legacyParameters() + if seed != nil { + return seed, nil + } + + addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + return nil, err + } + if len(addresses) > 0 { + for _, addr := range addresses { + // Re-import existing rows so startup and recovery can repair a + // DB-only address before deposit discovery depends on lnd's + // wallet view. + staticAddress, err := staticAddressFromParams(addr) + if err != nil { + return nil, err + } + + _, err = m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return nil, err + } + + m.activeStaticAddresses[string(addr.PkScript)] = addr } - return address, expiry, nil + return addresses[0], nil } - m.Unlock() - // We are fetching a new L402 token from the server. There is one static - // address per L402 token allowed. + // We are fetching a new L402 token from the server. The returned server + // key/expiry is the static address seed for all future client-derived + // addresses for this L402. err = m.cfg.FetchL402(ctx) if err != nil { - return nil, 0, err + return nil, err } clientPubKey, err := m.cfg.WalletKit.DeriveNextKey( ctx, swap.StaticAddressKeyFamily, ) if err != nil { - return nil, 0, err + return nil, err } - // Send our clientPubKey to the server and wait for the server to - // respond with he serverPubKey and the static address CSV expiry. protocolVersion := version.CurrentRPCProtocolVersion() resp, err := m.cfg.AddressClient.ServerNewAddress( ctx, &staticaddressrpc.ServerNewAddressRequest{ @@ -163,78 +260,121 @@ func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, }, ) if err != nil { - return nil, 0, err + return nil, err } if resp == nil { - return nil, 0, fmt.Errorf("missing server new address response") + return nil, fmt.Errorf("missing server new address response") } serverParams := resp.GetParams() if err := validateServerAddressParams(serverParams); err != nil { - return nil, 0, err + return nil, err } serverPubKey, err := btcec.ParsePubKey(serverParams.GetServerKey()) if err != nil { - return nil, 0, err + return nil, err } + return m.createAddressFromKey( + ctx, clientPubKey, serverPubKey, serverParams.Expiry, + version.AddressProtocolVersion(protocolVersion), + ) +} + +// NewReceiveAddress derives, stores, imports and activates the next receive +// family static address. It is used by `loop static new`. +func (m *Manager) NewReceiveAddress(ctx context.Context) (*Parameters, error) { + seed, err := m.EnsureStaticAddressSeed(ctx) + if err != nil { + return nil, err + } + + return m.newDerivedAddress(ctx, seed, swap.StaticMultiAddressKeyFamily) +} + +// NewChangeAddress derives, stores, imports and activates the next change +// family static address. Swap and withdrawal code calls this before submitting +// requests that require change. +func (m *Manager) NewChangeAddress(ctx context.Context) (*Parameters, error) { + seed, err := m.EnsureStaticAddressSeed(ctx) + if err != nil { + return nil, err + } + + return m.newDerivedAddress(ctx, seed, swap.StaticAddressChangeKeyFamily) +} + +func (m *Manager) newDerivedAddress(ctx context.Context, seed *Parameters, + keyFamily int32) (*Parameters, error) { + + m.Lock() + defer m.Unlock() + + clientPubKey, err := m.cfg.WalletKit.DeriveNextKey(ctx, keyFamily) + if err != nil { + return nil, err + } + + return m.createAddressFromKey( + ctx, clientPubKey, seed.ServerPubkey, seed.Expiry, + seed.ProtocolVersion, + ) +} + +func (m *Manager) createAddressFromKey(ctx context.Context, + clientPubKey *keychain.KeyDescriptor, serverPubKey *btcec.PublicKey, + expiry uint32, protocolVersion version.AddressProtocolVersion) ( + *Parameters, error) { + staticAddress, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(serverParams.Expiry), - clientPubKey.PubKey, serverPubKey, + input.MuSig2Version100RC2, int64(expiry), clientPubKey.PubKey, + serverPubKey, ) if err != nil { - return nil, 0, err + return nil, err } pkScript, err := staticAddress.StaticAddressScript() if err != nil { - return nil, 0, err + return nil, err } - // Create the static address from the parameters the server provided and - // store all parameters in the database. addrParams := &Parameters{ ClientPubkey: clientPubKey.PubKey, ServerPubkey: serverPubKey, PkScript: pkScript, - Expiry: serverParams.Expiry, + Expiry: expiry, KeyLocator: keychain.KeyLocator{ Family: clientPubKey.Family, Index: clientPubKey.Index, }, - ProtocolVersion: version.AddressProtocolVersion( - protocolVersion, - ), + ProtocolVersion: protocolVersion, 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 + return nil, 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 + return nil, err } - log.Infof("Imported static address taproot script to lnd wallet: %v", - addr) - - address, err := m.GetTaprootAddress( - clientPubKey.PubKey, serverPubKey, int64(serverParams.Expiry), - ) + addrParams.ID, err = m.cfg.Store.GetStaticAddressID(ctx, pkScript) if err != nil { - return nil, 0, err + return nil, err } - return address, int64(serverParams.Expiry), nil + m.activeStaticAddresses[string(pkScript)] = addrParams + + return addrParams, nil } // validateServerAddressParams validates the server-controlled static address @@ -272,6 +412,234 @@ 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 + + m.Lock() + existing, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + m.Unlock() + + return nil, false, err + } + + changed := false + importedBeforeCreate := false + var matched *Parameters + for _, existingAddr := range existing { + if !bytes.Equal(existingAddr.PkScript, addrParams.PkScript) { + continue + } + + matched = existingAddr + break + } + if matched == nil && addrParams.InitiationHeight <= 0 { + addrParams.InitiationHeight = m.currentHeight.Load() + } + + switch { + case matched == nil: + // 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 + } + importedBeforeCreate = true + + err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) + if err != nil { + m.Unlock() + + return nil, false, err + } + + addrParams.ID, err = m.cfg.Store.GetStaticAddressID( + ctx, addrParams.PkScript, + ) + if err != nil { + m.Unlock() + + return nil, false, err + } + + changed = true + + case !sameAddressParameters(matched, addrParams): + if sameDerivedAddressParameters(matched, addrParams) { + addrParams.ID = matched.ID + addrParams.InitiationHeight = matched.InitiationHeight + break + } + + m.Unlock() + + return nil, false, fmt.Errorf("existing static address differs from " + + "backup") + + default: + addrParams.ID = matched.ID + } + + if addrParams.InitiationHeight <= 0 { + addrParams.InitiationHeight = m.currentHeight.Load() + } + m.Unlock() + + if !importedBeforeCreate { + // 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 = changed || imported + } + m.Lock() + m.activeStaticAddresses[string(addrParams.PkScript)] = addrParams + 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 +} + +// sameDerivedAddressParameters compares persisted and recovered multi-address +// receive/change rows while ignoring initiation height. Recovery only stores a +// branch scan floor for these rows, not each child's exact issuance height. +func sameDerivedAddressParameters(a, b *Parameters) bool { + if a == nil || b == nil { + return false + } + if !isMultiAddressFamily(b.KeyLocator.Family) { + 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 +} + +// isMultiAddressFamily returns true for static-address receive/change branches +// that are derived locally from the L402-bound static-address seed. +func isMultiAddressFamily(family keychain.KeyFamily) bool { + return int32(family) == swap.StaticMultiAddressKeyFamily || + int32(family) == swap.StaticAddressChangeKeyFamily +} + +func staticAddressFromParams(params *Parameters) (*script.StaticAddress, + error) { + + if params == nil { + return nil, fmt.Errorf("missing static address parameters") + } + + return script.NewStaticAddress( + input.MuSig2Version100RC2, int64(params.Expiry), + params.ClientPubkey, params.ServerPubkey, + ) +} + +func (m *Manager) legacyParameters() *Parameters { + var legacy *Parameters + for _, params := range m.activeStaticAddresses { + if params == nil { + continue + } + + if legacy == nil || params.ID < legacy.ID { + legacy = params + } + } + + return legacy +} + // GetTaprootAddress returns a taproot address for the given client and server // public keys and expiry. func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, @@ -292,21 +660,17 @@ func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, // ListUnspentRaw returns a list of utxos at the static address. func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, - maxConfs int32) (*btcutil.AddressTaproot, []*lnwallet.Utxo, error) { - - addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) - switch { - case err != nil: - return nil, nil, err - - case len(addresses) == 0: - return nil, nil, nil + maxConfs int32) ([]*lnwallet.Utxo, error) { - case len(addresses) > 1: - return nil, nil, fmt.Errorf("more than one address found") + m.Lock() + active := make(map[string]struct{}, len(m.activeStaticAddresses)) + for pkScript := range m.activeStaticAddresses { + active[pkScript] = struct{}{} + } + m.Unlock() + if len(active) == 0 { + return nil, nil } - - staticAddress := addresses[0] // List all unspent utxos the wallet sees, regardless of the number of // confirmations. @@ -314,43 +678,37 @@ func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, ctx, minConfs, maxConfs, ) if err != nil { - return nil, nil, err + return nil, err } - // Filter the list of lnd's unspent utxos for the pkScript of our static - // address. + // Filter the list of lnd's unspent utxos for any locally active static + // address script. var filteredUtxos []*lnwallet.Utxo for _, utxo := range utxos { - if bytes.Equal(utxo.PkScript, staticAddress.PkScript) { + if _, ok := active[string(utxo.PkScript)]; ok { filteredUtxos = append(filteredUtxos, utxo) } } - taprootAddress, err := m.GetTaprootAddress( - staticAddress.ClientPubkey, staticAddress.ServerPubkey, - int64(staticAddress.Expiry), - ) - if err != nil { - return nil, nil, err - } - - return taprootAddress, filteredUtxos, nil + return 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) { - params, err := m.cfg.Store.GetAllStaticAddresses(ctx) + params, err := m.GetLegacyParameters(ctx) if err != nil { return nil, err } - if len(params) == 0 { - return nil, fmt.Errorf("no static address parameters found") + if params == nil { + return nil, ErrNoStaticAddress } - return params[0], nil + return params, nil } // GetStaticAddress returns a taproot address for the given client and server @@ -363,25 +721,53 @@ func (m *Manager) GetStaticAddress(ctx context.Context) (*script.StaticAddress, return nil, err } - address, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), - params.ClientPubkey, params.ServerPubkey, - ) - if err != nil { - return nil, err - } - - return address, nil + return staticAddressFromParams(params) } // ListUnspent returns a list of utxos at the static address. func (m *Manager) ListUnspent(ctx context.Context, minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) { - _, utxos, err := m.ListUnspentRaw(ctx, minConfs, maxConfs) + return m.ListUnspentRaw(ctx, minConfs, maxConfs) +} + +// GetLegacyParameters returns the legacy/root static address parameters. +func (m *Manager) GetLegacyParameters(ctx context.Context) (*Parameters, + error) { + + params, err := m.cfg.Store.GetLegacyParameters(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } if err != nil { return nil, err } - return utxos, nil + return params, nil +} + +// GetParameters returns active static address parameters for a pkScript. +func (m *Manager) GetParameters(pkScript []byte) *Parameters { + m.Lock() + defer m.Unlock() + + return m.activeStaticAddresses[string(pkScript)] +} + +// GetStaticAddressID returns the database row ID for a static address script. +func (m *Manager) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + return m.cfg.Store.GetStaticAddressID(ctx, pkScript) +} + +// IsOurPkScript returns true if the pkScript belongs to an active static +// address. +func (m *Manager) IsOurPkScript(pkScript []byte) bool { + return m.GetParameters(pkScript) != nil +} + +// GetAllAddresses returns all persisted static address parameters. +func (m *Manager) GetAllAddresses(ctx context.Context) ([]*Parameters, error) { + return m.cfg.Store.GetAllStaticAddresses(ctx) } diff --git a/staticaddr/address/manager_test.go b/staticaddr/address/manager_test.go index 5881bf848..a15fbef37 100644 --- a/staticaddr/address/manager_test.go +++ b/staticaddr/address/manager_test.go @@ -3,12 +3,16 @@ package address import ( "context" "encoding/hex" + "errors" + "fmt" "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 +37,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 +144,211 @@ 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, + ) + + addresses, err := testContext.manager.GetAllAddresses(ctxb) + require.NoError(t, err) + require.Len(t, addresses, 2) + require.EqualValues( + t, swap.StaticMultiAddressKeyFamily, + addresses[1].KeyLocator.Family, + ) +} + +// TestEnsureStaticAddressSeedDoesNotCreateReceiveAddress verifies that startup +// initialization can create the generation seed without also issuing the first +// multi-address receive child. +func TestEnsureStaticAddressSeedDoesNotCreateReceiveAddress(t *testing.T) { + ctxb := t.Context() + + testContext := NewAddressManagerTestContext(t) + + seed, err := testContext.manager.EnsureStaticAddressSeed(ctxb) + require.NoError(t, err) + require.EqualValues(t, swap.StaticAddressKeyFamily, seed.KeyLocator.Family) + + addresses, err := testContext.manager.GetAllAddresses(ctxb) + require.NoError(t, err) + require.Len(t, addresses, 1) + require.EqualValues( + t, swap.StaticAddressKeyFamily, + addresses[0].KeyLocator.Family, + ) + + params, err := testContext.manager.NewReceiveAddress(ctxb) + require.NoError(t, err) + require.EqualValues( + t, swap.StaticMultiAddressKeyFamily, + params.KeyLocator.Family, + ) + + addresses, err = testContext.manager.GetAllAddresses(ctxb) + require.NoError(t, err) + require.Len(t, addresses, 2) +} + +// 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 @@ -224,17 +445,80 @@ func TestNewAddressAcceptsMaxCSVExpiry(t *testing.T) { require.EqualValues(t, maxStaticAddressCSVExpiry, expiry) } +// TestRestoreDerivedAddressAcceptsDifferentInitiationHeight verifies that +// receive/change multi-address restores are idempotent even though the backup +// stores only the branch scan floor and not each child's original initiation +// height. +func TestRestoreDerivedAddressAcceptsDifferentInitiationHeight(t *testing.T) { + ctxb := t.Context() + + for _, family := range []int32{ + swap.StaticMultiAddressKeyFamily, + swap.StaticAddressChangeKeyFamily, + } { + t.Run(fmt.Sprintf("family-%d", family), func(t *testing.T) { + testContext := NewAddressManagerTestContext(t) + + keyDesc, err := testContext.mockLnd.WalletKit.DeriveKey( + ctxb, &keychain.KeyLocator{ + Family: keychain.KeyFamily(family), + Index: 4, + }, + ) + 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, + } + + _, restored, err := testContext.manager.RestoreAddress( + ctxb, addressParams, + ) + require.NoError(t, err) + require.True(t, restored) + + differentHeight := *addressParams + differentHeight.InitiationHeight = 456 + + _, restored, err = testContext.manager.RestoreAddress( + ctxb, &differentHeight, + ) + require.NoError(t, err) + require.False(t, restored) + require.Equal( + t, addressParams.InitiationHeight, + differentHeight.InitiationHeight, + ) + }) + } +} + // GenerateExpectedTaprootAddress generates the expected taproot address that // the predefined parameters are supposed to generate. func GenerateExpectedTaprootAddress(t *ManagerTestContext) ( *btcutil.AddressTaproot, error) { - keyIndex := int32(0) + keyIndex := int32(1) _, pubKey := test.CreateKey(keyIndex) keyDescriptor := &keychain.KeyDescriptor{ KeyLocator: keychain.KeyLocator{ - Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Family: keychain.KeyFamily(swap.StaticMultiAddressKeyFamily), Index: uint32(keyIndex), }, PubKey: pubKey, diff --git a/staticaddr/address/sql_store.go b/staticaddr/address/sql_store.go index 8d78e03c9..7433cfb2e 100644 --- a/staticaddr/address/sql_store.go +++ b/staticaddr/address/sql_store.go @@ -41,7 +41,14 @@ func (s *SqlStore) CreateStaticAddress(ctx context.Context, return s.baseDB.Queries.CreateStaticAddress(ctx, createArgs) } -// GetAllStaticAddresses returns all address known to the server. +// GetStaticAddressID retrieves the database ID for a static address script. +func (s *SqlStore) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + return s.baseDB.Queries.GetStaticAddressID(ctx, pkScript) +} + +// GetAllStaticAddresses returns all addresses known to the client. func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ([]*Parameters, error) { @@ -63,6 +70,18 @@ func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ([]*Parameters, return result, nil } +// GetLegacyParameters returns the first static address created for this L402. +func (s *SqlStore) GetLegacyParameters(ctx context.Context) (*Parameters, + error) { + + staticAddress, err := s.baseDB.Queries.GetLegacyAddress(ctx) + if err != nil { + return nil, err + } + + return s.toAddressParameters(staticAddress) +} + // toAddressParameters transforms a database representation of a static address // to an AddressParameters struct. func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( @@ -79,6 +98,7 @@ func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( } return &Parameters{ + ID: row.ID, ClientPubkey: clientPubkey, ServerPubkey: serverPubkey, PkScript: row.Pkscript, diff --git a/staticaddr/deposit/actions.go b/staticaddr/deposit/actions.go index 362417e7d..d6142d904 100644 --- a/staticaddr/deposit/actions.go +++ b/staticaddr/deposit/actions.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" @@ -27,9 +26,15 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, msgTx := wire.NewMsgTx(2) - params, err := f.cfg.AddressManager.GetStaticAddressParameters(ctx) + if f.deposit.AddressParams == nil { + return f.HandleError(fmt.Errorf("missing static address " + + "parameters")) + } + params := f.deposit.AddressParams + + address, err := f.deposit.GetStaticAddressScript() if err != nil { - return fsm.OnError + return f.HandleError(err) } // Add the deposit outpoint as input to the transaction. @@ -96,11 +101,6 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, return f.HandleError(err) } - address, err := f.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - return f.HandleError(err) - } - sig := rawSigs[0] msgTx.TxIn[0].Witness, err = address.GenTimeoutWitness(sig) if err != nil { @@ -131,14 +131,10 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, func (f *FSM) WaitForExpirySweepAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { - var txID *chainhash.Hash - // Only pass the txid if we know it from our own publication. - if f.deposit.ExpirySweepTxid != (chainhash.Hash{}) { - txID = &f.deposit.ExpirySweepTxid - } - + // Register by script only so an RBF replacement of the timeout sweep is + // still detected after restart with a stale ExpirySweepTxid. spendChan, errSpendChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( //nolint:lll - ctx, txID, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, + ctx, nil, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, int32(f.deposit.ConfirmationHeight), ) if err != nil { @@ -161,14 +157,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..15a913999 --- /dev/null +++ b/staticaddr/deposit/actions_test.go @@ -0,0 +1,181 @@ +package deposit + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/mock" + "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") + } +} + +func TestWaitForExpirySweepActionRegistersByScriptOnly(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + timeoutPkScript := []byte{0x51, 0x20, 0x01} + confChan := make(chan *chainntnfs.TxConfirmation, 1) + errChan := make(chan error, 1) + + chainNotifier := &MockChainNotifier{} + chainNotifier.On( + "RegisterConfirmationsNtfn", + mock.Anything, + mock.MatchedBy(func(txid *chainhash.Hash) bool { + return txid == nil + }), + timeoutPkScript, + int32(DefaultConfTarget), + int32(42), + ).Return(confChan, errChan, nil).Once() + + depositFSM := &FSM{ + cfg: &ManagerConfig{ + ChainNotifier: chainNotifier, + }, + deposit: &Deposit{ + ConfirmationHeight: 42, + ExpirySweepTxid: chainhash.Hash{9}, + TimeOutSweepPkScript: timeoutPkScript, + }, + } + + confirmedTx := wire.NewMsgTx(2) + confirmedTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: timeoutPkScript, + }) + confChan <- &chainntnfs.TxConfirmation{Tx: confirmedTx} + + event := depositFSM.WaitForExpirySweepAction(ctx, nil) + require.Equal(t, OnExpirySwept, event) + require.Equal(t, confirmedTx.TxHash(), depositFSM.deposit.ExpirySweepTxid) + chainNotifier.AssertExpectations(t) +} + +// 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..3aed0b4c0 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -9,12 +9,42 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" ) // ID is a unique identifier for a deposit. type ID [IdLength]byte +// DefaultRecoveryScanLimit is the highest child index scanned per static +// address key family when manually recovering a deposit unless the caller +// supplies a different scan limit. +const DefaultRecoveryScanLimit = 20 + +// RecoveryRequest describes one static-address output that should be verified +// on-chain and restored locally. +type RecoveryRequest struct { + TxID chainhash.Hash + VOut uint32 + HeightHint int32 + PkScript []byte + ScanLimit uint32 +} + +// RecoveryResult describes the restored deposit and matched static address. +type RecoveryResult struct { + OutPoint wire.OutPoint + Value btcutil.Amount + ConfirmationHeight int64 + AddressParams *address.Parameters + StaticAddress string + RecoveredAddress bool + RecoveredDeposit bool + DepositID ID +} + // FromByteSlice creates a deposit id from a byte slice. func (r *ID) FromByteSlice(b []byte) error { if len(b) != IdLength { @@ -29,6 +59,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 +79,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 @@ -62,6 +97,15 @@ type Deposit struct { // FinalizedWithdrawalTx is the coop-signed withdrawal transaction. It // is republished on new block arrivals and on client restarts. FinalizedWithdrawalTx *wire.MsgTx + + // AddressParams are the static address parameters that produced this + // deposit's pkScript. Spending code must use these per-deposit + // parameters rather than assuming all deposits belong to one address. + AddressParams *address.Parameters + + // AddressID is the database ID of the static address that produced this + // deposit's pkScript. + AddressID int32 } // IsInFinalState returns true if the deposit is final. @@ -69,15 +113,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 } @@ -110,6 +161,19 @@ func (d *Deposit) IsInStateNoLock(state fsm.StateType) bool { return d.state == state } +// GetStaticAddressScript reconstructs the static address script for this +// deposit's matched address parameters. +func (d *Deposit) GetStaticAddressScript() (*script.StaticAddress, error) { + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters") + } + + return script.NewStaticAddress( + input.MuSig2Version100RC2, int64(d.AddressParams.Expiry), + d.AddressParams.ClientPubkey, d.AddressParams.ServerPubkey, + ) +} + // GetRandomDepositID generates a random deposit ID. func GetRandomDepositID() (ID, error) { var id ID 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..5613fd7a1 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 @@ -175,13 +197,13 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, finalizedDepositChan chan wire.OutPoint, recoverStateMachine bool) (*FSM, error) { - params, err := cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, fmt.Errorf("unable to get static address "+ - "parameters: %w", err) + if deposit.AddressParams == nil { + return nil, fmt.Errorf("missing deposit static address " + + "parameters") } + params := deposit.AddressParams - address, err := cfg.AddressManager.GetStaticAddress(ctx) + address, err := deposit.GetStaticAddressScript() if err != nil { return nil, fmt.Errorf("unable to get static address: %w", err) } @@ -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. @@ -508,10 +545,10 @@ func (f *FSM) Errorf(format string, args ...any) { } // SignDescriptor returns the sign descriptor for the static address output. -func (f *FSM) SignDescriptor(ctx context.Context) (*lndclient.SignDescriptor, +func (f *FSM) SignDescriptor(_ context.Context) (*lndclient.SignDescriptor, error) { - address, err := f.cfg.AddressManager.GetStaticAddress(ctx) + address, err := f.deposit.GetStaticAddressScript() if err != nil { return nil, err } @@ -519,10 +556,10 @@ func (f *FSM) SignDescriptor(ctx context.Context) (*lndclient.SignDescriptor, return &lndclient.SignDescriptor{ WitnessScript: address.TimeoutLeaf.Script, KeyDesc: keychain.KeyDescriptor{ - PubKey: f.params.ClientPubkey, + PubKey: f.deposit.AddressParams.ClientPubkey, }, Output: wire.NewTxOut( - int64(f.deposit.Value), f.params.PkScript, + int64(f.deposit.Value), f.deposit.AddressParams.PkScript, ), HashType: txscript.SigHashDefault, InputIndex: 0, diff --git a/staticaddr/deposit/interface.go b/staticaddr/deposit/interface.go index c18011bc3..39d502672 100644 --- a/staticaddr/deposit/interface.go +++ b/staticaddr/deposit/interface.go @@ -23,6 +23,10 @@ type Store interface { // UpdateDeposit updates the deposit in the database. UpdateDeposit(ctx context.Context, deposit *Deposit) error + // UpdateRecoveredDeposit reactivates an existing deposit row from + // manually verified on-chain data and stores its static-address link. + UpdateRecoveredDeposit(ctx context.Context, deposit *Deposit) error + // GetDeposit retrieves a deposit with depositID from the database. GetDeposit(ctx context.Context, depositID ID) (*Deposit, error) @@ -40,6 +44,19 @@ type AddressManager interface { GetStaticAddressParameters(ctx context.Context) (*address.Parameters, error) + // GetStaticAddressID returns the database ID for the static address + // behind the given pkScript. + GetStaticAddressID(ctx context.Context, pkScript []byte) (int32, error) + + // GetParameters returns active static address parameters for the given + // pkScript. + GetParameters(pkScript []byte) *address.Parameters + + // RestoreAddress persists/imports the concrete static address behind a + // recovered deposit. + RestoreAddress(context.Context, + *address.Parameters) (*btcutil.AddressTaproot, bool, error) + // GetStaticAddress returns the deposit address for the given // client and server public keys. GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index af8820302..174adda07 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -1,25 +1,33 @@ package deposit import ( + "bytes" "context" "errors" "fmt" "sort" "sync" + "sync/atomic" "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet" ) 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 +41,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 +60,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 +81,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 +113,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 +123,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 +138,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 +161,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 +183,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 +209,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. @@ -171,6 +251,11 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { } for i, d := range deposits { + err = m.hydrateLegacyDepositAddressParams(ctx, d) + if err != nil { + return err + } + m.deposits[d.OutPoint] = deposits[i] // If the current deposit is final it wasn't active when we @@ -207,8 +292,71 @@ 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. +// hydrateLegacyDepositAddressParams fills in address parameters for deposits +// that predate the durable deposit-to-static-address link. Those deposits all +// belonged to the legacy/root static address, so the legacy address manager +// lookup preserves the behavior that existed before multi-address support. +func (m *Manager) hydrateLegacyDepositAddressParams(ctx context.Context, + deposits ...*Deposit) error { + + needsHydration := false + for _, d := range deposits { + if d != nil && d.AddressParams == nil { + needsHydration = true + break + } + } + if !needsHydration { + return nil + } + + if m.cfg == nil || m.cfg.AddressManager == nil { + return nil + } + + var legacyParams *address.Parameters + for _, d := range deposits { + if d == nil || d.AddressParams != nil { + continue + } + + if legacyParams == nil { + params, err := m.cfg.AddressManager. + GetStaticAddressParameters(ctx) + if err != nil { + return fmt.Errorf("unable to recover legacy "+ + "static address parameters for deposit %v: %w", + d.OutPoint, err) + } + if params == nil { + return fmt.Errorf("missing legacy static address "+ + "parameters for deposit %v", d.OutPoint) + } + + if params.ID <= 0 { + params.ID, err = m.cfg.AddressManager. + GetStaticAddressID(ctx, params.PkScript) + if err != nil { + return fmt.Errorf("unable to recover legacy "+ + "static address ID for deposit %v: %w", + d.OutPoint, err) + } + } + + legacyParams = params + } + + d.AddressParams = legacyParams + d.AddressID = legacyParams.ID + } + + return nil +} + +// 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 +366,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 +383,608 @@ 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 fmt.Errorf("unable to list new deposits: %w", err) + 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 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 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) +} + +type verifiedRecoveryDeposit struct { + outPoint wire.OutPoint + value btcutil.Amount + confirmationHeight int64 + block *wire.MsgBlock +} + +// RecoverDeposit verifies a caller-supplied static-address output on-chain, +// restores the matching concrete static address, directly creates/reactivates +// the local deposit row, and starts the normal deposit FSM. +func (m *Manager) RecoverDeposit(ctx context.Context, + req *RecoveryRequest) (*RecoveryResult, error) { + + if req == nil { + return nil, fmt.Errorf("missing recovery request") + } + + m.reconcileMu.Lock() + defer m.reconcileMu.Unlock() + + verified, err := m.verifyRecoveryOutput(ctx, req) + if err != nil { + return nil, err + } + + err = m.ensureRecoveryOutpointUnspent(ctx, verified) + if err != nil { + return nil, err + } + + addrParams, err := m.findRecoveryAddressParams(ctx, req) + if err != nil { + return nil, err + } + + staticAddress, recoveredAddress, err := m.cfg.AddressManager. + RestoreAddress(ctx, addrParams) + if err != nil { + return nil, err + } + if addrParams.ID <= 0 { + addrParams.ID, err = m.cfg.AddressManager.GetStaticAddressID( + ctx, addrParams.PkScript, + ) + if err != nil { + return nil, err + } + } + + recoveredDeposit, recovered, err := m.restoreVerifiedDeposit( + ctx, verified, addrParams, + ) + if err != nil { + return nil, err + } + + return &RecoveryResult{ + OutPoint: recoveredDeposit.OutPoint, + Value: recoveredDeposit.Value, + ConfirmationHeight: recoveredDeposit.ConfirmationHeight, + AddressParams: recoveredDeposit.AddressParams, + StaticAddress: staticAddress.String(), + RecoveredAddress: recoveredAddress, + RecoveredDeposit: recovered, + DepositID: recoveredDeposit.ID, + }, nil +} + +func (m *Manager) verifyRecoveryOutput(ctx context.Context, + req *RecoveryRequest) (*verifiedRecoveryDeposit, error) { + + if req.HeightHint <= 0 { + return nil, fmt.Errorf("height hint must be positive") + } + if len(req.PkScript) == 0 { + return nil, fmt.Errorf("missing pkScript") + } + + confChan, errChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn( + ctx, &req.TxID, req.PkScript, 1, req.HeightHint, + lndclient.WithIncludeBlock(), + ) + if err != nil { + return nil, err + } + + var confTx *chainntnfs.TxConfirmation + select { + case confTx = <-confChan: + + case err = <-errChan: + return nil, err + + case <-ctx.Done(): + return nil, ctx.Err() + } + + if confTx == nil || confTx.Tx == nil { + return nil, fmt.Errorf("missing confirmation transaction") + } + if confTx.BlockHeight == 0 { + return nil, fmt.Errorf("missing confirmation height") + } + txHash := confTx.Tx.TxHash() + if txHash != req.TxID { + return nil, fmt.Errorf("confirmation txid %v does not match "+ + "requested txid %v", txHash, req.TxID) + } + if req.VOut >= uint32(len(confTx.Tx.TxOut)) { + return nil, fmt.Errorf("vout %d not found in transaction", + req.VOut) + } + + txOut := confTx.Tx.TxOut[req.VOut] + if !bytes.Equal(txOut.PkScript, req.PkScript) { + return nil, fmt.Errorf("confirmed output pkScript mismatch") + } + if txOut.Value <= 0 { + return nil, fmt.Errorf("confirmed output has invalid value %d", + txOut.Value) + } + + return &verifiedRecoveryDeposit{ + outPoint: wire.OutPoint{ + Hash: req.TxID, + Index: req.VOut, + }, + value: btcutil.Amount(txOut.Value), + confirmationHeight: int64(confTx.BlockHeight), + block: confTx.Block, + }, nil +} + +func (m *Manager) ensureRecoveryOutpointUnspent(ctx context.Context, + verified *verifiedRecoveryDeposit) error { + + if m.cfg.ChainKit == nil { + return errors.New("chain kit client required for deposit recovery") + } + + _, bestHeight, err := m.cfg.ChainKit.GetBestBlock(ctx) + if err != nil { + return fmt.Errorf("unable to get best block: %w", err) + } + if bestHeight < int32(verified.confirmationHeight) { + return fmt.Errorf("best height %d is below confirmation "+ + "height %d", bestHeight, verified.confirmationHeight) + } + m.currentHeight.Store(uint32(bestHeight)) + + for height := verified.confirmationHeight; height <= int64(bestHeight); height++ { + block := verified.block + if height != verified.confirmationHeight || block == nil { + blockHash, err := m.cfg.ChainKit.GetBlockHash(ctx, height) + if err != nil { + return fmt.Errorf("unable to get block hash at "+ + "height %d: %w", height, err) + } + + block, err = m.cfg.ChainKit.GetBlock(ctx, blockHash) + if err != nil { + return fmt.Errorf("unable to get block at "+ + "height %d: %w", height, err) + } + } + + if blockSpendsOutpoint(block, verified.outPoint) { + return fmt.Errorf("deposit outpoint %v is already spent", + verified.outPoint) + } + } + return nil } +func blockSpendsOutpoint(block *wire.MsgBlock, outpoint wire.OutPoint) bool { + if block == nil { + return false + } + + for _, tx := range block.Transactions { + for _, txIn := range tx.TxIn { + if txIn.PreviousOutPoint == outpoint { + return true + } + } + } + + return false +} + +func (m *Manager) findRecoveryAddressParams(ctx context.Context, + req *RecoveryRequest) (*address.Parameters, error) { + + if params := m.cfg.AddressManager.GetParameters(req.PkScript); params != nil { + return params, nil + } + + seedParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) + if err != nil { + return nil, err + } + if seedParams == nil { + return nil, fmt.Errorf("missing static address seed") + } + + params, matched, err := m.matchRecoveryAddressParams( + ctx, req, seedParams, + ) + if err != nil { + return nil, err + } + if matched { + return params, nil + } + + return nil, fmt.Errorf("no static address child matched pkScript") +} + +func (m *Manager) matchRecoveryAddressParams(ctx context.Context, + req *RecoveryRequest, seedParams *address.Parameters) ( + *address.Parameters, bool, error) { + + if seedParams == nil { + return nil, false, nil + } + if seedParams.ServerPubkey == nil { + return nil, false, fmt.Errorf("missing static address seed " + + "server pubkey") + } + if seedParams.Expiry == 0 { + return nil, false, fmt.Errorf("missing static address seed expiry") + } + if bytes.Equal(seedParams.PkScript, req.PkScript) { + return seedParams, true, nil + } + + scanLimit := req.ScanLimit + if scanLimit == 0 { + scanLimit = DefaultRecoveryScanLimit + } + + for _, family := range recoveryKeyFamilies(seedParams.KeyLocator.Family) { + for index := uint32(0); index <= scanLimit; index++ { + params, err := m.deriveRecoveryAddress( + ctx, seedParams, family, index, req.HeightHint, + ) + if err != nil { + return nil, false, err + } + + if bytes.Equal(params.PkScript, req.PkScript) { + return params, true, nil + } + } + } + + return nil, false, nil +} + +func recoveryKeyFamilies(legacyFamily keychain.KeyFamily) []keychain.KeyFamily { + if legacyFamily == 0 { + legacyFamily = keychain.KeyFamily(swap.StaticAddressKeyFamily) + } + + candidates := []keychain.KeyFamily{ + legacyFamily, + keychain.KeyFamily(swap.StaticMultiAddressKeyFamily), + keychain.KeyFamily(swap.StaticAddressChangeKeyFamily), + } + + families := make([]keychain.KeyFamily, 0, len(candidates)) + seen := make(map[keychain.KeyFamily]struct{}, len(candidates)) + for _, family := range candidates { + if _, ok := seen[family]; ok { + continue + } + + seen[family] = struct{}{} + families = append(families, family) + } + + return families +} + +func (m *Manager) deriveRecoveryAddress(ctx context.Context, + seedParams *address.Parameters, family keychain.KeyFamily, + index uint32, initiationHeight int32) (*address.Parameters, error) { + + locator := keychain.KeyLocator{ + Family: family, + Index: index, + } + clientKey, err := m.cfg.WalletKit.DeriveKey(ctx, &locator) + if err != nil { + return nil, fmt.Errorf("unable to derive static address child "+ + "%d:%d: %w", family, index, err) + } + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(seedParams.Expiry), + clientKey.PubKey, seedParams.ServerPubkey, + ) + if err != nil { + return nil, err + } + + pkScript, err := staticAddress.StaticAddressScript() + if err != nil { + return nil, err + } + + return &address.Parameters{ + ClientPubkey: clientKey.PubKey, + ServerPubkey: seedParams.ServerPubkey, + Expiry: seedParams.Expiry, + PkScript: pkScript, + KeyLocator: locator, + ProtocolVersion: seedParams.ProtocolVersion, + InitiationHeight: initiationHeight, + }, nil +} + +func (m *Manager) restoreVerifiedDeposit(ctx context.Context, + verified *verifiedRecoveryDeposit, + addrParams *address.Parameters) (*Deposit, bool, error) { + + if addrParams == nil || addrParams.ID <= 0 { + return nil, false, fmt.Errorf("static address ID must be set") + } + + existing, err := m.cfg.Store.DepositForOutpoint( + ctx, verified.outPoint.String(), + ) + switch { + case err == nil: + return m.reactivateVerifiedDeposit(ctx, existing, verified, + addrParams) + + case errors.Is(err, ErrDepositNotFound): + return m.createVerifiedDeposit(ctx, verified, addrParams) + + default: + return nil, false, err + } +} + +func (m *Manager) createVerifiedDeposit(ctx context.Context, + verified *verifiedRecoveryDeposit, + addrParams *address.Parameters) (*Deposit, bool, error) { + + timeoutSweepPkScript, err := m.nextTimeoutSweepPkScript(ctx) + if err != nil { + return nil, false, err + } + + id, err := GetRandomDepositID() + if err != nil { + return nil, false, err + } + + d := &Deposit{ + ID: id, + state: Deposited, + OutPoint: verified.outPoint, + Value: verified.value, + ConfirmationHeight: verified.confirmationHeight, + TimeOutSweepPkScript: timeoutSweepPkScript, + AddressParams: addrParams, + AddressID: addrParams.ID, + } + + err = m.cfg.Store.CreateDeposit(ctx, d) + if err != nil { + return nil, false, err + } + + err = m.rememberAndStartDeposit(ctx, d) + if err != nil { + return nil, false, err + } + + return d, true, nil +} + +func (m *Manager) reactivateVerifiedDeposit(ctx context.Context, d *Deposit, + verified *verifiedRecoveryDeposit, addrParams *address.Parameters) ( + *Deposit, bool, error) { + + d.Lock() + if d.Value != verified.value { + d.Unlock() + return nil, false, fmt.Errorf("existing deposit %v has value "+ + "%v, recovered output has value %v", verified.outPoint, + d.Value, verified.value) + } + + state := d.state + switch state { + case Deposited: + // Idempotent recovery. We still refresh the verified height and + // address linkage below, but report that no deposit row needed to be + // recovered. + + case Replaced: + + default: + d.Unlock() + return nil, false, fmt.Errorf("existing deposit %v is in "+ + "state %s", verified.outPoint, state) + } + + if d.TimeOutSweepPkScript == nil { + timeoutSweepPkScript, err := m.nextTimeoutSweepPkScript(ctx) + if err != nil { + d.Unlock() + return nil, false, err + } + d.TimeOutSweepPkScript = timeoutSweepPkScript + } + + d.OutPoint = verified.outPoint + d.ConfirmationHeight = verified.confirmationHeight + d.AddressParams = addrParams + d.AddressID = addrParams.ID + d.SetStateNoLock(Deposited) + + err := m.cfg.Store.UpdateRecoveredDeposit(ctx, d) + if err != nil { + d.SetStateNoLock(state) + d.Unlock() + return nil, false, err + } + + recovered := state == Replaced + d.Unlock() + err = m.rememberAndStartDeposit(ctx, d) + if err != nil { + return nil, false, err + } + + return d, recovered, nil +} + +func (m *Manager) rememberAndStartDeposit(ctx context.Context, + d *Deposit) error { + + m.mu.Lock() + m.deposits[d.OutPoint] = d + delete(m.missingDeposits, d.OutPoint) + _, active := m.activeDeposits[d.OutPoint] + m.mu.Unlock() + + if active { + return nil + } + + return m.startDepositFsm(ctx, d) +} + +func (m *Manager) nextTimeoutSweepPkScript(ctx context.Context) ([]byte, error) { + addr, err := m.cfg.WalletKit.NextAddr( + ctx, lnwallet.DefaultAccountName, + walletrpc.AddressType_TAPROOT_PUBKEY, false, + ) + if err != nil { + return nil, err + } + + return txscript.PayToAddrScript(addr) +} + +// 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 } @@ -297,13 +1007,29 @@ func (m *Manager) createNewDeposit(ctx context.Context, if err != nil { return nil, err } + + addressParams := m.cfg.AddressManager.GetParameters(utxo.PkScript) + if addressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %v", utxo.OutPoint) + } + + addressID, err := m.cfg.AddressManager.GetStaticAddressID( + ctx, utxo.PkScript, + ) + if err != nil { + return nil, err + } + deposit := &Deposit{ ID: id, state: Deposited, OutPoint: utxo.OutPoint, Value: utxo.Value, - ConfirmationHeight: int64(blockHeight), + ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, + AddressParams: addressParams, + AddressID: addressID, } err = m.cfg.Store.CreateDeposit(ctx, deposit) @@ -318,37 +1044,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) } - notifChan, errChan, err := - m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, &utxo.OutPoint.Hash, addressParams.PkScript, - MinConfs, addressParams.InitiationHeight, + 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 0, err + 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() } - select { - case tx := <-notifChan: - return tx.BlockHeight, nil + return nil +} - case err := <-errChan: - return 0, err +// 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 + } - case <-ctx.Done(): - return 0, ctx.Err() + 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 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 + } } + + return nil +} + +// 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{}{} + } + + 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,9 +1468,32 @@ 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) + deposits, err := m.cfg.Store.AllDeposits(ctx) + if err != nil { + return nil, err + } + + err = m.hydrateLegacyDepositAddressParams(ctx, deposits...) + if err != nil { + return nil, err + } + + return deposits, nil } // UpdateDeposit overrides all fields of the deposit with given ID in the store. @@ -597,6 +1551,11 @@ func (m *Manager) DepositsForOutpoints(ctx context.Context, return nil, err } + err = m.hydrateLegacyDepositAddressParams(ctx, deposit) + if err != nil { + return nil, err + } + deposits = append(deposits, deposit) } 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..e23324328 --- /dev/null +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -0,0 +1,457 @@ +package deposit + +import ( + "context" + "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( + "GetParameters", mock.Anything, + ).Return(&address.Parameters{ + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }) + + 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( + "GetParameters", mock.Anything, + ).Return(&address.Parameters{ + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }) + + 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, + AddressParams: &address.Parameters{ + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + ProtocolVersion: version.ProtocolVersion_V0, + }, + } + 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..143dfc32f 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -108,15 +108,82 @@ type mockAddressManager struct { mock.Mock } +func (m *mockAddressManager) hasExpectation(method string) bool { + for _, call := range m.ExpectedCalls { + if call.Method == method { + return true + } + } + + return false +} + func (m *mockAddressManager) GetStaticAddressParameters(ctx context.Context) ( *address.Parameters, error) { args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } return args.Get(0).(*address.Parameters), args.Error(1) } +func (m *mockAddressManager) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + if !m.hasExpectation("GetStaticAddressID") { + return 1, nil + } + + args := m.Called(ctx, pkScript) + + return int32(args.Int(0)), args.Error(1) +} + +func (m *mockAddressManager) GetParameters( + pkScript []byte) *address.Parameters { + + if !m.hasExpectation("GetParameters") { + return &address.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + } + } + + args := m.Called(pkScript) + if args.Get(0) == nil { + return nil + } + + return args.Get(0).(*address.Parameters) +} + +func (m *mockAddressManager) RestoreAddress(_ context.Context, + params *address.Parameters) (*btcutil.AddressTaproot, bool, error) { + + if !m.hasExpectation("RestoreAddress") { + params.ID = 1 + addr, err := m.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, + int64(params.Expiry), + ) + return addr, true, err + } + + args := m.Called(params) + if args.Get(0) == nil { + return nil, args.Bool(1), args.Error(2) + } + + return args.Get(0).(*btcutil.AddressTaproot), args.Bool(1), + args.Error(2) +} + func (m *mockAddressManager) GetStaticAddress(ctx context.Context) ( *script.StaticAddress, error) { @@ -159,6 +226,13 @@ func (s *mockStore) UpdateDeposit(ctx context.Context, deposit *Deposit) error { return args.Error(0) } +func (s *mockStore) UpdateRecoveredDeposit(ctx context.Context, + deposit *Deposit) error { + + args := s.Called(ctx, deposit) + return args.Error(0) +} + func (s *mockStore) GetDeposit(ctx context.Context, depositID ID) (*Deposit, error) { @@ -170,6 +244,10 @@ func (s *mockStore) DepositForOutpoint(ctx context.Context, outpoint string) (*Deposit, error) { args := s.Called(ctx, outpoint) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Deposit), args.Error(1) } @@ -216,6 +294,46 @@ 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) { + + args := m.Called() + return args.Get(0).(*wire.MsgBlock), args.Error(1) +} + +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) { + + args := m.Called() + return args.Get(0).(chainhash.Hash), args.Error(1) +} + // TestManager checks that the manager processes the right channel notifications // while a deposit is expiring. func TestManager(t *testing.T) { @@ -234,6 +352,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 +426,153 @@ 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) +} + +func TestRecoverDepositsHydratesLegacyAddressParams(t *testing.T) { + ctx := context.Background() + + id, err := GetRandomDepositID() + require.NoError(t, err) + + storedDeposit := &Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 3, + }, + state: Deposited, + Value: btcutil.Amount(100000), + ConfirmationHeight: 42, + TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69}, + } + + testContext := newManagerTestContextWithStoredDeposits( + t, []*Deposit{storedDeposit}, nil, + ) + + storedDeposit.AddressParams = nil + storedDeposit.AddressID = 0 + + err = testContext.manager.recoverDeposits(ctx) + require.NoError(t, err) + require.NotNil(t, storedDeposit.AddressParams) + require.NotZero(t, storedDeposit.AddressID) +} + +func TestGetAllDepositsHydratesLegacyAddressParams(t *testing.T) { + ctx := context.Background() + + id, err := GetRandomDepositID() + require.NoError(t, err) + + storedDeposit := &Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 4, + }, + state: Withdrawn, + Value: btcutil.Amount(100000), + ConfirmationHeight: 42, + } + + testContext := newManagerTestContextWithStoredDeposits( + t, []*Deposit{storedDeposit}, nil, + ) + + storedDeposit.AddressParams = nil + storedDeposit.AddressID = 0 + + deposits, err := testContext.manager.GetAllDeposits(ctx) + require.NoError(t, err) + require.Len(t, deposits, 1) + require.NotNil(t, deposits[0].AddressParams) + require.NotZero(t, deposits[0].AddressID) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { @@ -320,19 +589,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 +602,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Index: 0xffffffff, }, } - require.NoError(t, err) + storedDeposits := []*Deposit{ { ID: ID, @@ -355,6 +614,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) @@ -363,15 +642,23 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { "UpdateDeposit", mock.Anything, mock.Anything, ).Return(nil) + staticAddress, addrParams := generateStaticAddress( + context.Background(), mockLnd, lndContext.T, + ) + for _, storedDeposit := range storedDeposits { + if storedDeposit.AddressParams == nil { + storedDeposit.AddressParams = addrParams + storedDeposit.AddressID = addrParams.ID + } + } + mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return(&address.Parameters{ - Expiry: defaultExpiry, - }, nil) + ).Return(addrParams, nil) 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( @@ -405,9 +692,6 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { blockErrChan: blockErrChan, } - staticAddress := generateStaticAddress( - context.Background(), testContext, - ) mockAddressManager.On( "GetStaticAddress", mock.Anything, ).Return(staticAddress, nil) @@ -415,19 +699,30 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { return testContext } -func generateStaticAddress(ctx context.Context, - t *ManagerTestContext) *script.StaticAddress { +func generateStaticAddress(ctx context.Context, mockLnd *test.LndMockServices, + t *testing.T) (*script.StaticAddress, *address.Parameters) { - keyDescriptor, err := t.mockLnd.WalletKit.DeriveNextKey( + keyDescriptor, err := mockLnd.WalletKit.DeriveNextKey( ctx, swap.StaticAddressKeyFamily, ) - require.NoError(t.context.T, err) + require.NoError(t, err) staticAddress, err := script.NewStaticAddress( input.MuSig2Version100RC2, int64(defaultExpiry), keyDescriptor.PubKey, defaultServerPubkey, ) - require.NoError(t.context.T, err) + require.NoError(t, err) - return staticAddress + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + return staticAddress, &address.Parameters{ + ID: 1, + ClientPubkey: keyDescriptor.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDescriptor.KeyLocator, + ProtocolVersion: 0, + } } diff --git a/staticaddr/deposit/recovery_test.go b/staticaddr/deposit/recovery_test.go new file mode 100644 index 000000000..f31205eb7 --- /dev/null +++ b/staticaddr/deposit/recovery_test.go @@ -0,0 +1,345 @@ +package deposit + +import ( + "context" + "encoding/binary" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRecoveryKeyFamiliesDedupes(t *testing.T) { + require.Equal(t, []keychain.KeyFamily{ + keychain.KeyFamily(swap.StaticMultiAddressKeyFamily), + keychain.KeyFamily(swap.StaticAddressChangeKeyFamily), + }, recoveryKeyFamilies( + keychain.KeyFamily(swap.StaticMultiAddressKeyFamily), + )) +} + +func TestFindRecoveryAddressParamsMatchesFamilies(t *testing.T) { + ctx := context.Background() + + for _, family := range []int32{ + swap.StaticAddressKeyFamily, + swap.StaticMultiAddressKeyFamily, + swap.StaticAddressChangeKeyFamily, + } { + t.Run(fmt.Sprintf("%d", family), func(t *testing.T) { + wallet := &familyAwareWalletKit{} + seed := recoveryAddressParams( + t, wallet, swap.StaticAddressKeyFamily, 0, + ) + target := recoveryAddressParams(t, wallet, family, 3) + + addrMgr := new(mockAddressManager) + addrMgr.On("GetParameters", target.PkScript).Return(nil) + addrMgr.On("GetStaticAddressParameters", mock.Anything). + Return(seed, nil) + + manager := NewManager(&ManagerConfig{ + AddressManager: addrMgr, + WalletKit: wallet, + }) + + params, err := manager.findRecoveryAddressParams( + ctx, &RecoveryRequest{ + PkScript: target.PkScript, + HeightHint: 50, + ScanLimit: 5, + }, + ) + require.NoError(t, err) + require.Equal(t, target.PkScript, params.PkScript) + require.EqualValues(t, family, params.KeyLocator.Family) + require.EqualValues(t, 3, params.KeyLocator.Index) + }) + } +} + +func TestRecoverDepositCreatesNewDeposit(t *testing.T) { + ctx := context.Background() + manager, req, store := newRecoveryManagerTest(t, nil, nil) + + result, err := manager.RecoverDeposit(ctx, req) + require.NoError(t, err) + require.True(t, result.RecoveredDeposit) + require.True(t, result.RecoveredAddress) + require.Equal(t, req.TxID.String()+":1", result.OutPoint.String()) + + require.NotNil(t, store.created) + require.Equal(t, req.TxID, store.created.Hash) + require.EqualValues(t, 1, store.created.Index) + require.EqualValues(t, 77, store.created.AddressID) + require.NotNil(t, store.created.AddressParams) + require.Equal(t, req.PkScript, store.created.AddressParams.PkScript) + require.True(t, store.created.IsInState(Deposited)) +} + +func TestRecoverDepositReactivatesReplacedDeposit(t *testing.T) { + ctx := context.Background() + existing := &Deposit{ + state: Replaced, + Value: 125_000, + TimeOutSweepPkScript: []byte{0x51}, + } + manager, req, store := newRecoveryManagerTest(t, existing, nil) + existing.OutPoint = wire.OutPoint{ + Hash: req.TxID, + Index: req.VOut, + } + + result, err := manager.RecoverDeposit(ctx, req) + require.NoError(t, err) + require.True(t, result.RecoveredDeposit) + require.Same(t, existing, store.updated) + require.EqualValues(t, 77, existing.AddressID) + require.Equal(t, req.PkScript, existing.AddressParams.PkScript) + require.True(t, existing.IsInState(Deposited)) +} + +func TestRecoverDepositMismatchedExistingValueErrors(t *testing.T) { + ctx := context.Background() + existing := &Deposit{ + state: Replaced, + Value: 1, + } + manager, req, _ := newRecoveryManagerTest(t, existing, nil) + existing.OutPoint = wire.OutPoint{ + Hash: req.TxID, + Index: req.VOut, + } + + _, err := manager.RecoverDeposit(ctx, req) + require.ErrorContains(t, err, "has value") +} + +func TestRecoverDepositAlreadySpentErrors(t *testing.T) { + ctx := context.Background() + spendTx := wire.NewMsgTx(2) + manager, req, _ := newRecoveryManagerTest(t, nil, spendTx) + spendTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: req.TxID, + Index: req.VOut, + }, + }) + + _, err := manager.RecoverDeposit(ctx, req) + require.ErrorContains(t, err, "already spent") +} + +type recoveryStore struct { + existing *Deposit + created *Deposit + updated *Deposit +} + +func (s *recoveryStore) CreateDeposit(_ context.Context, d *Deposit) error { + s.created = d + return nil +} + +func (s *recoveryStore) UpdateDeposit(context.Context, *Deposit) error { + return nil +} + +func (s *recoveryStore) UpdateRecoveredDeposit(_ context.Context, + d *Deposit) error { + + s.updated = d + return nil +} + +func (s *recoveryStore) GetDeposit(context.Context, ID) (*Deposit, error) { + return nil, ErrDepositNotFound +} + +func (s *recoveryStore) DepositForOutpoint(context.Context, + string) (*Deposit, error) { + + if s.existing == nil { + return nil, ErrDepositNotFound + } + + return s.existing, nil +} + +func (s *recoveryStore) AllDeposits(context.Context) ([]*Deposit, error) { + return nil, nil +} + +func newRecoveryManagerTest(t *testing.T, existing *Deposit, + spendTx *wire.MsgTx) (*Manager, *RecoveryRequest, *recoveryStore) { + + t.Helper() + + const ( + height = int32(123) + targetVout = uint32(1) + ) + + lnd := test.NewMockLnd() + wallet := &familyAwareWalletKit{WalletKitClient: lnd.WalletKit} + + seed := recoveryAddressParams( + t, wallet, swap.StaticAddressKeyFamily, 0, + ) + target := recoveryAddressParams( + t, wallet, swap.StaticMultiAddressKeyFamily, 2, + ) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + tx.AddTxOut(&wire.TxOut{ + Value: 125_000, + PkScript: target.PkScript, + }) + txid := tx.TxHash() + req := &RecoveryRequest{ + TxID: txid, + VOut: targetVout, + HeightHint: height - 10, + PkScript: target.PkScript, + ScanLimit: 5, + } + + confChan := make(chan *chainntnfs.TxConfirmation, 1) + confErrChan := make(chan error, 1) + confChan <- &chainntnfs.TxConfirmation{ + BlockHeight: uint32(height), + Tx: tx, + Block: blockWithTx(tx), + } + + chainNotifier := new(MockChainNotifier) + chainNotifier.On( + "RegisterConfirmationsNtfn", mock.Anything, mock.Anything, + mock.Anything, int32(1), req.HeightHint, + ).Return(confChan, confErrChan, nil) + + chainKit := new(MockChainKit) + bestHeight := height + if spendTx != nil { + bestHeight = height + 1 + chainKit.On("GetBlockHash").Return(chainhash.Hash{9}, nil).Once() + chainKit.On("GetBlock").Return(blockWithTx(spendTx), nil).Once() + } + chainKit.On("GetBestBlock", mock.Anything).Return( + chainhash.Hash{}, bestHeight, nil, + ) + + staticAddress, err := target.TaprootAddress(&chaincfg.TestNet3Params) + require.NoError(t, err) + + addrMgr := new(mockAddressManager) + addrMgr.On("GetParameters", target.PkScript).Return(nil) + addrMgr.On("GetStaticAddressParameters", mock.Anything).Return(seed, nil) + addrMgr.On("RestoreAddress", mock.Anything).Run( + func(args mock.Arguments) { + params := args.Get(0).(*address.Parameters) + params.ID = 77 + }, + ).Return(staticAddress, true, nil) + + store := &recoveryStore{existing: existing} + manager := NewManager(&ManagerConfig{ + AddressManager: addrMgr, + ChainKit: chainKit, + Store: store, + WalletKit: wallet, + ChainNotifier: chainNotifier, + Signer: lnd.Signer, + }) + + return manager, req, store +} + +func blockWithTx(tx *wire.MsgTx) *wire.MsgBlock { + return &wire.MsgBlock{ + Transactions: []*wire.MsgTx{tx}, + } +} + +type familyAwareWalletKit struct { + lndclient.WalletKitClient +} + +func (w *familyAwareWalletKit) DeriveKey(_ context.Context, + locator *keychain.KeyLocator) (*keychain.KeyDescriptor, error) { + + _, pubKey := familyAwareKey(locator.Family, locator.Index) + return &keychain.KeyDescriptor{ + KeyLocator: *locator, + PubKey: pubKey, + }, nil +} + +func (w *familyAwareWalletKit) NextAddr(context.Context, string, + walletrpc.AddressType, bool) (btcutil.Address, error) { + + return btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), &chaincfg.TestNet3Params, + ) +} + +func familyAwareKey(family keychain.KeyFamily, + index uint32) (*btcec.PrivateKey, *btcec.PublicKey) { + + var key [32]byte + binary.BigEndian.PutUint32(key[24:], uint32(family)) + binary.BigEndian.PutUint32(key[28:], index+1) + + return btcec.PrivKeyFromBytes(key[:]) +} + +func recoveryAddressParams(t *testing.T, wallet *familyAwareWalletKit, + family int32, index uint32) *address.Parameters { + + t.Helper() + + locator := keychain.KeyLocator{ + Family: keychain.KeyFamily(family), + Index: index, + } + clientKey, err := wallet.DeriveKey(context.Background(), &locator) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + clientKey.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + return &address.Parameters{ + ClientPubkey: clientKey.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: locator, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 1, + } +} + +var _ lndclient.WalletKitClient = (*familyAwareWalletKit)(nil) diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index d9f4249fe..e6538960a 100644 --- a/staticaddr/deposit/sql_store.go +++ b/staticaddr/deposit/sql_store.go @@ -6,14 +6,19 @@ import ( "database/sql" "encoding/hex" "errors" + "fmt" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" ) @@ -49,6 +54,13 @@ func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { Amount: int64(deposit.Value), ConfirmationHeight: deposit.ConfirmationHeight, TimeoutSweepPkScript: deposit.TimeOutSweepPkScript, + StaticAddressID: sql.NullInt32{}, + } + if deposit.AddressID > 0 { + createArgs.StaticAddressID = sql.NullInt32{ + Int32: deposit.AddressID, + Valid: true, + } } updateArgs := sqlc.InsertDepositUpdateParams{ @@ -126,6 +138,45 @@ func (s *SqlStore) UpdateDeposit(ctx context.Context, deposit *Deposit) error { }) } +// UpdateRecoveredDeposit reactivates an existing deposit row from verified +// on-chain recovery data and records its static-address linkage. +func (s *SqlStore) UpdateRecoveredDeposit(ctx context.Context, + deposit *Deposit) error { + + if deposit.AddressID <= 0 { + return fmt.Errorf("static address ID must be set") + } + + updateArgs := sqlc.UpdateRecoveredDepositParams{ + DepositID: deposit.ID[:], + TxHash: deposit.Hash[:], + OutIndex: int32(deposit.Index), + Amount: int64(deposit.Value), + ConfirmationHeight: deposit.ConfirmationHeight, + TimeoutSweepPkScript: deposit.TimeOutSweepPkScript, + StaticAddressID: sql.NullInt32{ + Int32: deposit.AddressID, + Valid: true, + }, + } + + updateStateArgs := sqlc.InsertDepositUpdateParams{ + DepositID: deposit.ID[:], + UpdateTimestamp: s.clock.Now().UTC(), + UpdateState: string(deposit.GetState()), + } + + return s.baseDB.ExecTx(ctx, &loopdb.SqliteTxOptions{}, + func(q *sqlc.Queries) error { + err := q.UpdateRecoveredDeposit(ctx, updateArgs) + if err != nil { + return err + } + + return q.InsertDepositUpdate(ctx, updateStateArgs) + }) +} + // GetDeposit retrieves the deposit from the database. func (s *SqlStore) GetDeposit(ctx context.Context, id ID) (*Deposit, error) { var deposit *Deposit @@ -143,7 +194,9 @@ func (s *SqlStore) GetDeposit(ctx context.Context, id ID) (*Deposit, error) { return err } - deposit, err = ToDeposit(row, latestUpdate) + deposit, err = toDeposit( + depositRowFromGet(row), latestUpdate, + ) if err != nil { return err } @@ -189,7 +242,9 @@ func (s *SqlStore) DepositForOutpoint(ctx context.Context, return err } - deposit, err = ToDeposit(row, latestUpdate) + deposit, err = toDeposit( + depositRowFromOutpoint(row), latestUpdate, + ) if err != nil { return err } @@ -241,8 +296,105 @@ func (s *SqlStore) AllDeposits(ctx context.Context) ([]*Deposit, error) { return allDeposits, nil } -// ToDeposit converts an sql deposit to a deposit. -func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, +// ToDeposit converts an sql deposit row with joined static address metadata to +// a deposit. +func ToDeposit(row sqlc.AllDepositsRow, lastUpdate sqlc.DepositUpdate) (*Deposit, + error) { + + return toDeposit(depositRowFromAll(row), lastUpdate) +} + +type depositRow struct { + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func depositRowFromAll(row sqlc.AllDepositsRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func depositRowFromGet(row sqlc.GetDepositRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func depositRowFromOutpoint(row sqlc.DepositForOutpointRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func toDeposit(row depositRow, lastUpdate sqlc.DepositUpdate) (*Deposit, error) { id := ID{} @@ -292,7 +444,7 @@ func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, swapHash = &hash } - return &Deposit{ + deposit := &Deposit{ ID: id, state: fsm.StateType(lastUpdate.UpdateState), OutPoint: wire.OutPoint{ @@ -305,5 +457,58 @@ func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, ExpirySweepTxid: expirySweepTxid, SwapHash: swapHash, FinalizedWithdrawalTx: finalizedWithdrawalTx, - }, nil + AddressID: row.StaticAddressID.Int32, + } + + if row.StaticAddressID.Valid { + clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) + if err != nil { + return nil, err + } + + serverPubkey, err := btcec.ParsePubKey(row.ServerPubkey) + if err != nil { + return nil, err + } + + deposit.AddressParams = &address.Parameters{ + ID: row.StaticAddressID.Int32, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: uint32(row.Expiry.Int32), + PkScript: row.Pkscript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + row.ClientKeyFamily.Int32, + ), + Index: uint32(row.ClientKeyIndex.Int32), + }, + ProtocolVersion: version.AddressProtocolVersion( + row.ProtocolVersion.Int32, + ), + InitiationHeight: row.InitiationHeight.Int32, + } + } + + return deposit, nil +} + +// BatchSetStaticAddressID sets the static address id for all deposits that +// predate the deposit-to-address schema link. +func (s *SqlStore) BatchSetStaticAddressID(ctx context.Context, + staticAddressID int32) error { + + if staticAddressID <= 0 { + return fmt.Errorf("static address ID must be set") + } + + return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), + func(q *sqlc.Queries) error { + return q.SetAllNullDepositsStaticAddressID( + ctx, sql.NullInt32{ + Int32: staticAddressID, + Valid: true, + }, + ) + }) } diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index 5656e386a..b5ded65aa 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -24,13 +24,13 @@ func TestToDeposit(t *testing.T) { tests := []struct { name string - row sqlc.Deposit + row sqlc.AllDepositsRow lastUpdate sqlc.DepositUpdate expectErr bool }{ { name: "fully valid data", - row: sqlc.Deposit{ + row: sqlc.AllDepositsRow{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, @@ -44,7 +44,7 @@ func TestToDeposit(t *testing.T) { }, { name: "fully valid data", - row: sqlc.Deposit{ + row: sqlc.AllDepositsRow{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 70a27811f..0493b8600 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 @@ -82,13 +104,36 @@ func (f *FSM) InitHtlcAction(ctx context.Context, } swapInvoiceAmt := swapAmount - f.loopIn.QuotedSwapFee + var changeOutput *swapserverrpc.StaticAddressChangeOutput + if hasChange { + changeAmount := f.loopIn.ExpectedChangeAmount() + f.loopIn.ChangeAddressParams, err = + f.cfg.AddressManager.NewChangeAddress(ctx) + if err != nil { + err = fmt.Errorf("unable to create static address "+ + "change output: %w", err) + + return returnError(err) + } + + changeOutput, err = staticutil.ChangeOutput( + f.loopIn.ChangeAddressParams, changeAmount, + ) + if err != nil { + err = fmt.Errorf("unable to prepare static address "+ + "change output: %w", err) + + return returnError(err) + } + } + // Generate random preimage. var swapPreimage lntypes.Preimage if _, err = rand.Read(swapPreimage[:]); err != nil { 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 +145,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,24 +164,40 @@ 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(), ) + depositClientPubkeys, err := staticutil.DepositClientPubkeys( + f.loopIn.Deposits, + ) + if err != nil { + err = fmt.Errorf("unable to prepare static address input "+ + "proofs: %w", err) + + return returnError(err) + } + loopInReq := &swapserverrpc.ServerStaticAddressLoopInRequest{ - SwapHash: f.loopIn.SwapHash[:], - DepositOutpoints: f.loopIn.DepositOutpoints, - Amount: uint64(f.loopIn.SelectedAmount), - HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), - SwapInvoice: f.loopIn.SwapInvoice, - ProtocolVersion: version.CurrentRPCProtocolVersion(), - UserAgent: loop.UserAgent(f.loopIn.Initiator), - PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, - Fast: f.loopIn.Fast, + SwapHash: f.loopIn.SwapHash[:], + DepositOutpoints: f.loopIn.DepositOutpoints, + Amount: uint64(f.loopIn.SelectedAmount), + HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), + SwapInvoice: f.loopIn.SwapInvoice, + ProtocolVersion: version.CurrentRPCProtocolVersion(), + UserAgent: loop.UserAgent(f.loopIn.Initiator), + PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, + Fast: f.loopIn.Fast, + DepositToClientPubkeys: depositClientPubkeys, + ChangeOutput: changeOutput, } if f.loopIn.LastHop != nil { loopInReq.LastHop = f.loopIn.LastHop @@ -149,7 +210,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 +232,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 +246,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 +255,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 +263,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 +271,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 +293,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 +307,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 +323,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 +337,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 +347,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) } - return OnHtlcInitiated + // 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 false, nil } // SignHtlcTxAction is called if the htlc was initialized and the server @@ -300,6 +499,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) @@ -321,8 +532,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, // rates. createSession := staticutil.CreateMusig2Sessions htlcSessions, clientHtlcNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { err = fmt.Errorf("unable to create musig2 sessions: %w", err) @@ -332,8 +542,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, defer f.cleanUpSessions(ctx, htlcSessions) htlcSessionsHighFee, highFeeNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { return f.HandleError(err) @@ -341,8 +550,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, defer f.cleanUpSessions(ctx, htlcSessionsHighFee) htlcSessionsExtremelyHighFee, extremelyHighNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { err = fmt.Errorf("unable to convert nonces: %w", err) @@ -511,7 +719,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 +750,40 @@ 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, startedAt time.Time) { + if deadlineStarted || invoice.State == invoices.ContractCanceled { + return + } + + timeout := f.loopIn.PaymentTimeoutDuration() + if !startedAt.IsZero() { + timeout -= time.Since(startedAt) + if timeout < 0 { + timeout = 0 + } + } + + 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,18 +796,67 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // Cancel the lndclient invoice subscription. cancelInvoiceSubscription() - err = f.cfg.InvoicesClient.CancelInvoice(ctx, f.loopIn.SwapHash) + // Reuse the same helper as InitHtlcAction so timeout cleanup follows + // the same detached-context path as early-init cleanup. + f.cancelSwapInvoice(ctx) + } + + riskDecisionTime := func(decision ConfirmationRiskDecision) time.Time { + if f.cfg.Store == nil { + return time.Now() + } + + storedLoopIn, err := f.cfg.Store.GetLoopInByHash( + ctx, f.loopIn.SwapHash, + ) if err != nil { - f.Warnf("unable to cancel invoice "+ - "for swap hash: %v", err) + f.Warnf("unable to reload persisted risk decision for "+ + "swap %v: %v", f.loopIn.SwapHash, err) + + return time.Now() + } + + if storedLoopIn == nil || + storedLoopIn.ConfirmationRiskDecision != decision || + storedLoopIn.ConfirmationRiskDecisionTime.IsZero() { + + return time.Now() } + + f.loopIn.ConfirmationRiskDecision = + storedLoopIn.ConfirmationRiskDecision + f.loopIn.ConfirmationRiskDecisionTime = + storedLoopIn.ConfirmationRiskDecisionTime + + return storedLoopIn.ConfirmationRiskDecisionTime + } + + switch f.loopIn.ConfirmationRiskDecision { + case ConfirmationRiskDecisionAccepted: + startPaymentDeadline( + "recovered risk accepted notification", + f.loopIn.ConfirmationRiskDecisionTime, + ) + + case ConfirmationRiskDecisionRejected: + cancelInvoiceSubscription() + f.cancelSwapInvoice(ctx) + + return f.HandleError(errors.New( + "server rejected confirmation risk wait", + )) } for { select { - case <-htlcConfChan: + case conf := <-htlcConfChan: f.Infof("htlc tx confirmed") + err = f.recordConfirmedHtlc(ctx, conf, htlc.PkScript) + if err != nil { + return f.HandleError(err) + } + htlcConfirmed = true case err = <-htlcErrConfChan: @@ -593,6 +881,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // confirmation and re-register for the next // confirmation. htlcConfirmed = false + err = f.clearConfirmedHtlc(ctx) + if err != nil { + return f.HandleError(err) + } htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { @@ -603,19 +895,87 @@ 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 } + startedAt := riskDecisionTime( + ConfirmationRiskDecisionAccepted, + ) + f.loopIn.ConfirmationRiskDecision = + ConfirmationRiskDecisionAccepted + f.loopIn.ConfirmationRiskDecisionTime = startedAt + startPaymentDeadline( + "risk accepted notification", + f.loopIn.ConfirmationRiskDecisionTime, + ) + + case riskRejected, ok := <-riskRejectedChan: + if !ok { + riskRejectedChan = nil + continue + } + + if !bytes.Equal( + riskRejected.SwapHash, f.loopIn.SwapHash[:], + ) { + + continue + } + + cancelInvoiceSubscription() + f.cancelSwapInvoice(ctx) + decisionTime := riskDecisionTime( + ConfirmationRiskDecisionRejected, + ) + f.loopIn.ConfirmationRiskDecision = + ConfirmationRiskDecisionRejected + f.loopIn.ConfirmationRiskDecisionTime = decisionTime + + 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", + time.Time{}, + ) + } + // 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 +1001,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 +1031,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(): @@ -705,6 +1055,51 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } } +func (f *FSM) recordConfirmedHtlc(ctx context.Context, + conf *chainntnfs.TxConfirmation, htlcPkScript []byte) error { + + if conf == nil || conf.Tx == nil { + return errors.New("htlc confirmation missing transaction") + } + if f.cfg.Store == nil { + return errors.New("missing static address loop-in store") + } + + tx := conf.Tx + txHash := tx.TxHash() + for idx, txOut := range tx.TxOut { + if !bytes.Equal(txOut.PkScript, htlcPkScript) { + continue + } + + f.loopIn.HtlcTxHash = &txHash + f.loopIn.HtlcOutputIndex = uint32(idx) + f.loopIn.HtlcOutputValue = btcutil.Amount(txOut.Value) + + return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn) + } + + return fmt.Errorf("confirmed htlc tx %v missing expected htlc "+ + "output", txHash) +} + +func (f *FSM) clearConfirmedHtlc(ctx context.Context) error { + if f.loopIn.HtlcTxHash == nil && f.loopIn.HtlcOutputIndex == 0 && + f.loopIn.HtlcOutputValue == 0 { + + return nil + } + if f.cfg.Store == nil { + return errors.New("missing static address loop-in store") + } + + f.loopIn.HtlcTxHash = nil + f.loopIn.HtlcOutputIndex = 0 + f.loopIn.HtlcOutputValue = 0 + + return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn) +} + // htlcTimeoutSweepRetryDelay is the delay between retries when publishing the // htlc timeout sweep transaction fails. const htlcTimeoutSweepRetryDelay = time.Hour @@ -824,9 +1219,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 +1229,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..0a49e5fb6 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -18,6 +18,7 @@ import ( "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/zpay32" @@ -55,10 +56,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{ @@ -136,6 +137,7 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { t.Parallel() mockLnd := test.NewMockLnd() + _, clientPubkey := test.CreateKey(20) _, serverKey := test.CreateKey(21) server := &mockStaticAddressServer{ @@ -150,6 +152,9 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { Index: 0, }, Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientPubkey, + }, } loopIn := &StaticAddressLoopIn{ @@ -183,6 +188,13 @@ 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, + ) + require.Equal( + t, clientPubkey.SerializeCompressed(), + server.request.DepositToClientPubkeys[dep.String()], + ) _, routeHints, _, _, err := swap.DecodeInvoice( mockLnd.ChainParams, server.request.SwapInvoice, @@ -192,6 +204,78 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { test.RequireRouteHintsEqual(t, loopIn.RouteHints, routeHints) } +// TestInitHtlcActionSendsChangeOutput asserts that fractional loop-ins create +// and send an operation-specific static change output to the server. +func TestInitHtlcActionSendsChangeOutput(t *testing.T) { + t.Parallel() + + mockLnd := test.NewMockLnd() + _, depositClientPubkey := test.CreateKey(31) + _, changeClientPubkey := test.CreateKey(32) + _, serverKey := test.CreateKey(33) + + server := &mockStaticAddressServer{ + response: testStaticAddressLoopInResponse( + serverKey.SerializeCompressed(), + ), + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + }, + Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: depositClientPubkey, + }, + } + changeParams := &address.Parameters{ + ID: 1, + ClientPubkey: changeClientPubkey, + PkScript: []byte{0x51, 0x20, 0x01}, + } + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{dep}, + DepositOutpoints: []string{dep.OutPoint.String()}, + SelectedAmount: 300_000, + QuotedSwapFee: 1_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: 3_600, + } + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + Server: server, + AddressManager: &mockAddressManager{params: changeParams}, + DepositManager: &noopDepositManager{}, + LndClient: mockLnd.Client, + WalletKit: mockLnd.WalletKit, + ChainParams: mockLnd.ChainParams, + Store: &mockStore{}, + ValidateLoopInContract: testValidateLoopInContract, + MaxStaticAddrHtlcFeePercentage: 1, + MaxStaticAddrHtlcBackupFeePercentage: 1, + }, + loopIn: loopIn, + } + + event := f.InitHtlcAction(t.Context(), nil) + require.Equal(t, OnHtlcInitiated, event) + require.Nil(t, f.LastActionError) + require.NotNil(t, server.request.ChangeOutput) + require.EqualValues(t, 200_000, server.request.ChangeOutput.Amount) + require.Equal( + t, changeClientPubkey.SerializeCompressed(), + server.request.ChangeOutput.ClientPubkey, + ) + require.Equal(t, changeParams.PkScript, server.request.ChangeOutput.PkScript) + require.Same(t, changeParams, loopIn.ChangeAddressParams) +} + // mockStaticAddressServer captures static-address loop-in requests in tests. type mockStaticAddressServer struct { swapserverrpc.StaticAddressServerClient @@ -230,6 +314,70 @@ func testStaticAddressLoopInResponse( } } +type recordingLoopInStore struct { + mockStore + + updates []*StaticAddressLoopIn +} + +func (s *recordingLoopInStore) UpdateLoopIn(_ context.Context, + loopIn *StaticAddressLoopIn) error { + + s.updates = append(s.updates, loopIn) + + return nil +} + +// TestRecordConfirmedHtlcPersistsOutpoint verifies that the FSM records the +// exact confirmed server HTLC output before the timeout branch can sweep it. +func TestRecordConfirmedHtlcPersistsOutpoint(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{1, 2, 4}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + } + htlc, err := loopIn.getHtlc(test.NewMockLnd().ChainParams) + require.NoError(t, err) + + htlcValue := int64(123_456) + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: []byte{0x51}, + }) + tx.AddTxOut(&wire.TxOut{ + Value: htlcValue, + PkScript: htlc.PkScript, + }) + + store := &recordingLoopInStore{} + f := &FSM{ + cfg: &Config{Store: store}, + loopIn: loopIn, + } + + err = f.recordConfirmedHtlc( + t.Context(), &chainntnfs.TxConfirmation{Tx: tx}, + htlc.PkScript, + ) + require.NoError(t, err) + + txHash := tx.TxHash() + require.NotNil(t, loopIn.HtlcTxHash) + require.Equal(t, txHash, *loopIn.HtlcTxHash) + require.EqualValues(t, 1, loopIn.HtlcOutputIndex) + require.EqualValues(t, htlcValue, loopIn.HtlcOutputValue) + require.Len(t, store.updates, 1) +} + // testStaticAddressRouteHints returns deterministic route hints for static // loop-in invoice regression tests. func testStaticAddressRouteHints() [][]zpay32.HopHint { @@ -271,60 +419,1416 @@ 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) + }() -// TransitionDeposits implements DepositManager with a no-op. -func (n *noopDepositManager) TransitionDeposits(context.Context, - []*deposit.Deposit, fsm.EventType, fsm.StateType) error { + waitForMonitorSubscriptions(t, ctx, mockLnd) - return nil + 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") + } } -// DepositsForOutpoints implements DepositManager with a no-op. -func (n *noopDepositManager) DepositsForOutpoints(context.Context, []string, - bool) ([]*deposit.Deposit, error) { +// TestMonitorInvoiceAndHtlcTxUsesPersistedAcceptedRiskTime verifies that live +// risk notifications use the durable receipt time, not the local channel +// receive time, when reconstructing the payment deadline. +func TestMonitorInvoiceAndHtlcTxUsesPersistedAcceptedRiskTime(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() - return nil, nil + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{4, 5, 7} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{8}, + 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: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + 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, + Store: &mockStore{ + loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ + swapHash: { + ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted, + ConfirmationRiskDecisionTime: time.Now().Add( + -time.Minute, + ), + }, + }, + }, + } + + 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 risk acceptance: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + notificationMgr.riskAccepted <- &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + } + + 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") + } } -// GetActiveDepositsInState implements DepositManager with a no-op. -func (n *noopDepositManager) GetActiveDepositsInState(fsm.StateType) ( - []*deposit.Deposit, 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, 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") + } +} + +// TestMonitorInvoiceAndHtlcTxRecoversAcceptedRiskDecision verifies that a +// persisted risk acceptance restarts the payment deadline with elapsed time +// preserved after restart. +func TestMonitorInvoiceAndHtlcTxRecoversAcceptedRiskDecision(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{5, 6, 8} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{12}, + 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: 1, + ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted, + ConfirmationRiskDecisionTime: time.Now().Add(-time.Minute), + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + 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: + 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") + } +} + +// TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision verifies that a +// persisted risk rejection is terminal after restart without waiting for a +// replayed server notification. +func TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision(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{5, 6, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{13}, + 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, + ConfirmationRiskDecision: ConfirmationRiskDecisionRejected, + ConfirmationRiskDecisionTime: time.Now(), + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + 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: + 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") + } +} + +// 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) { + + 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) +} + +// 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) { + + 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() + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{{ + Value: 200_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, + }}, + 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() + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{{ + Value: 200_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, + }}, + 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 +} + +// NewChangeAddress returns configured parameters for tests that need change. +func (m *mockAddressManager) NewChangeAddress(_ context.Context) ( + *address.Parameters, error) { + + return m.params, 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..403cc29b1 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" @@ -41,6 +42,10 @@ type AddressManager interface { // GetStaticAddress returns the deposit address for the given client and // server public keys. GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) + + // NewChangeAddress derives and persists a fresh static address from the + // change key family for this operation's change output. + NewChangeAddress(ctx context.Context) (*address.Parameters, error) } // DepositManager handles the interaction of loop-ins with deposits. @@ -88,6 +93,11 @@ type StaticAddressLoopInStore interface { // IsStored checks if the loop-in is already stored in the database. IsStored(ctx context.Context, swapHash lntypes.Hash) (bool, error) + // RecordStaticAddressRiskDecision persists the server's + // confirmation-risk decision for the loop-in identified by swapHash. + RecordStaticAddressRiskDecision(ctx context.Context, + swapHash lntypes.Hash, decision ConfirmationRiskDecision) error + // GetLoopInByHash returns the loop-in swap with the given hash. GetLoopInByHash(ctx context.Context, swapHash lntypes.Hash) ( *StaticAddressLoopIn, error) @@ -106,10 +116,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..8fcf9d2cc 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -31,6 +31,23 @@ import ( "github.com/lightningnetwork/lnd/zpay32" ) +// ConfirmationRiskDecision records the server's decision on whether it accepts +// waiting for low-confirmation deposits before paying a static loop-in invoice. +type ConfirmationRiskDecision string + +const ( + // ConfirmationRiskDecisionNone means no risk decision has been received. + ConfirmationRiskDecisionNone ConfirmationRiskDecision = "" + + // ConfirmationRiskDecisionAccepted means the server accepted waiting for + // deposit confirmations and the payment deadline has started. + ConfirmationRiskDecisionAccepted ConfirmationRiskDecision = "accepted" + + // ConfirmationRiskDecisionRejected means the server stopped waiting for + // deposit confirmations before paying the invoice. + ConfirmationRiskDecisionRejected ConfirmationRiskDecision = "rejected" +) + // StaticAddressLoopIn represents the in-memory loop-in information. type StaticAddressLoopIn struct { // SwapHash is the hashed preimage of the swap invoice. It represents @@ -93,8 +110,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 @@ -106,6 +121,15 @@ type StaticAddressLoopIn struct { // on the server side for this static loop in. Fast bool + // ConfirmationRiskDecision records the server's persisted decision on + // low-confirmation deposit risk. + ConfirmationRiskDecision ConfirmationRiskDecision + + // ConfirmationRiskDecisionTime is when loopd persisted the server risk + // decision. It is used to reconstruct payment-deadline timeouts after + // restart. + ConfirmationRiskDecisionTime time.Time + // state is the current state of the swap. state fsm.StateType @@ -139,6 +163,11 @@ type StaticAddressLoopIn struct { // Address is the address script that is used for the swap. Address *script.StaticAddress + // ChangeAddressParams are the static address parameters for the change + // output that belongs to this swap. It is set only when SelectedAmount + // leaves non-dust change. + ChangeAddressParams *address.Parameters + // HTLC fields. // HtlcTxFeeRate is the fee rate that is used for the htlc transaction. @@ -155,6 +184,16 @@ type StaticAddressLoopIn struct { // HtlcTimeoutSweepTxHash is the hash of the htlc timeout sweep tx. HtlcTimeoutSweepTxHash *chainhash.Hash + // HtlcTxHash is the hash of the confirmed htlc tx published by the + // server. + HtlcTxHash *chainhash.Hash + + // HtlcOutputIndex is the output index of the confirmed htlc output. + HtlcOutputIndex uint32 + + // HtlcOutputValue is the value of the confirmed htlc output. + HtlcOutputValue btcutil.Amount + // HtlcTimeoutSweepAddress HtlcTimeoutSweepAddress btcutil.Address @@ -177,19 +216,36 @@ func (l *StaticAddressLoopIn) signMusig2Tx(ctx context.Context, musig2sessions []*input.MuSig2SessionInfo, counterPartyNonces [][musig2.PubNonceSize]byte) ([][]byte, error) { - prevOuts, err := staticutil.ToPrevOuts( - l.Deposits, l.AddressParams.PkScript, - ) + prevOuts, err := staticutil.ToPrevOuts(l.Deposits) if err != nil { return nil, err } prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) outpoints := l.Outpoints() + if len(tx.TxIn) != len(outpoints) { + return nil, fmt.Errorf("htlc tx input count %d does not "+ + "match deposits %d", len(tx.TxIn), len(outpoints)) + } + if len(musig2sessions) != len(outpoints) { + return nil, fmt.Errorf("musig2 session count %d does not "+ + "match deposits %d", len(musig2sessions), len(outpoints)) + } + if len(counterPartyNonces) != len(outpoints) { + return nil, fmt.Errorf("server nonce count %d does not "+ + "match deposits %d", len(counterPartyNonces), + len(outpoints)) + } + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) sigs := make([][]byte, len(outpoints)) for idx, outpoint := range outpoints { + if musig2sessions[idx] == nil { + return nil, fmt.Errorf("missing musig2 session for "+ + "deposit input %d", idx) + } + if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint, outpoint) { @@ -262,11 +318,10 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, // change. var ( swapAmt = l.TotalDepositAmount() - changeAmount btcutil.Amount + changeAmount = l.ExpectedChangeAmount() ) if l.SelectedAmount > 0 { swapAmt = l.SelectedAmount - changeAmount = l.TotalDepositAmount() - l.SelectedAmount } // Calculate htlc tx fee for server provided fee rate. @@ -302,9 +357,14 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, // We expect change to be sent back to our static address output script. if changeAmount > 0 { + if l.ChangeAddressParams == nil { + return nil, fmt.Errorf("missing static address change " + + "parameters") + } + msgTx.AddTxOut(&wire.TxOut{ Value: int64(changeAmount), - PkScript: l.AddressParams.PkScript, + PkScript: l.ChangeAddressParams.PkScript, }) } @@ -364,36 +424,18 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, return nil, err } - htlcTx, err := l.createHtlcTx( - network, l.HtlcTxFeeRate, maxFeePercentage, + htlcOutpoint, htlcOutValue, err := l.confirmedHtlcOutpoint( + network, maxFeePercentage, ) if err != nil { return nil, err } - // The HTLC output is always at index 0 (createHtlcTx adds it first). - // If there is a change output, it is at index 1. Verify this invariant - // so we fail fast if createHtlcTx's layout ever changes. - const htlcInputIndex = uint32(0) - if len(htlcTx.TxOut) == 2 { - if bytes.Equal( - htlcTx.TxOut[0].PkScript, l.AddressParams.PkScript, - ) { - - return nil, fmt.Errorf("htlc tx output layout " + - "invariant violated: expected HTLC output " + - "at index 0, got change output") - } - } - // Add the htlc input. sweepTx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: htlcTx.TxHash(), - Index: htlcInputIndex, - }, - SignatureScript: htlc.SigScript, - Sequence: htlc.SuccessSequence(), + PreviousOutPoint: htlcOutpoint, + SignatureScript: htlc.SigScript, + Sequence: htlc.SuccessSequence(), }) // Add the sweep output. @@ -404,7 +446,6 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, fee := feeRate.FeeForWeight(weightEstimator.Weight()) - htlcOutValue := htlcTx.TxOut[htlcInputIndex].Value output := &wire.TxOut{ Value: htlcOutValue - int64(fee), PkScript: sweepPkScript, @@ -443,6 +484,56 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, return sweepTx, nil } +// confirmedHtlcOutpoint returns the exact confirmed htlc outpoint when it has +// been persisted. Older loop-ins fall back to reconstructing the standard-fee +// htlc tx, which was the historical behavior before we stored the actual +// server-published variant. +func (l *StaticAddressLoopIn) confirmedHtlcOutpoint( + network *chaincfg.Params, maxFeePercentage float64) (wire.OutPoint, + int64, error) { + + if l.HtlcTxHash != nil { + if l.HtlcOutputValue <= 0 { + return wire.OutPoint{}, 0, fmt.Errorf("missing htlc "+ + "output value for confirmed htlc tx %v", + l.HtlcTxHash) + } + + return wire.OutPoint{ + Hash: *l.HtlcTxHash, + Index: l.HtlcOutputIndex, + }, int64(l.HtlcOutputValue), nil + } + + htlcTx, err := l.createHtlcTx( + network, l.HtlcTxFeeRate, maxFeePercentage, + ) + if err != nil { + return wire.OutPoint{}, 0, err + } + + // The HTLC output is always at index 0 (createHtlcTx adds it first). + // If there is a change output, it is at index 1. Verify this invariant + // so we fail fast if createHtlcTx's layout ever changes. + const htlcInputIndex = uint32(0) + if len(htlcTx.TxOut) == 2 && l.ChangeAddressParams != nil { + if bytes.Equal( + htlcTx.TxOut[0].PkScript, + l.ChangeAddressParams.PkScript, + ) { + + return wire.OutPoint{}, 0, fmt.Errorf("htlc tx " + + "output layout invariant violated: expected " + + "HTLC output at index 0, got change output") + } + } + + return wire.OutPoint{ + Hash: htlcTx.TxHash(), + Index: htlcInputIndex, + }, htlcTx.TxOut[htlcInputIndex].Value, nil +} + // pubkeyTo33ByteSlice converts a pubkey to a 33 byte slice. func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte { var pubkeyBytes [33]byte @@ -464,14 +555,45 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { return total } +// ExpectedChangeAmount returns the change that a fractional loop-in should send +// to its generated static change address. A full-amount loop-in has no change. +func (l *StaticAddressLoopIn) ExpectedChangeAmount() btcutil.Amount { + if l.SelectedAmount <= 0 { + return 0 + } + + totalDepositAmount := l.TotalDepositAmount() + changeAmount := totalDepositAmount - l.SelectedAmount + if changeAmount <= 0 || changeAmount >= totalDepositAmount { + return 0 + } + + return changeAmount +} + // 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/loopin_test.go b/staticaddr/loopin/loopin_test.go index 16ed8af14..9918de8e9 100644 --- a/staticaddr/loopin/loopin_test.go +++ b/staticaddr/loopin/loopin_test.go @@ -78,7 +78,8 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { Hash: chainhash.Hash{0xaa}, Index: 0, }, - Value: depositValue, + Value: depositValue, + AddressParams: addrParams, }, } @@ -97,7 +98,7 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Deposits: deposits, - AddressParams: addrParams, + ChangeAddressParams: addrParams, HtlcTxFeeRate: feeRate, SelectedAmount: selectedAmount, PaymentTimeoutSeconds: 3600, @@ -148,6 +149,76 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { "the HTLC output") } +// TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint verifies that timeout sweeps +// spend the actual server-published HTLC tx variant once it has been recorded. +func TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + network := &chaincfg.RegressionNetParams + staticAddr, err := newStaticAddress( + clientKey.PubKey(), serverKey.PubKey(), 4032, + ) + require.NoError(t, err) + + pkScript, err := staticAddr.StaticAddressScript() + require.NoError(t, err) + + addrParams := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PkScript: pkScript, + Expiry: 4032, + ProtocolVersion: version.ProtocolVersion_V0, + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0xbb}, + Index: 0, + }, + Value: 500_000, + AddressParams: addrParams, + } + + confirmedHtlcHash := chainhash.Hash{0xcc} + confirmedHtlcValue := btcutil.Amount(275_000) + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{3, 2, 1}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Deposits: []*deposit.Deposit{dep}, + HtlcTxFeeRate: chainfee.SatPerKWeight(253), + HtlcTxHash: &confirmedHtlcHash, + HtlcOutputIndex: 2, + HtlcOutputValue: confirmedHtlcValue, + } + + sweepAddr, err := btcutil.NewAddressTaproot(make([]byte, 32), network) + require.NoError(t, err) + + sweepTx, err := loopIn.createHtlcSweepTx( + t.Context(), &noopSigner{}, sweepAddr, + chainfee.SatPerKWeight(253), network, + uint32(loopIn.HtlcCltvExpiry)+1, 1, + ) + require.NoError(t, err) + require.Len(t, sweepTx.TxIn, 1) + require.Equal( + t, wire.OutPoint{ + Hash: confirmedHtlcHash, + Index: 2, + }, sweepTx.TxIn[0].PreviousOutPoint, + ) + require.Less(t, sweepTx.TxOut[0].Value, int64(confirmedHtlcValue)) + require.Greater(t, sweepTx.TxOut[0].Value, int64(0)) +} + // newStaticAddress creates a StaticAddress for testing. func newStaticAddress(clientKey, serverKey *btcec.PublicKey, csvExpiry int64) (*script.StaticAddress, error) { diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 444ab5856..1263243fd 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math" "slices" "sort" "sync/atomic" @@ -20,7 +21,6 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" - "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" "github.com/lightninglabs/loop/swapserverrpc" @@ -79,6 +79,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 @@ -326,7 +330,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, // If the user selected an amount that is less than the total deposit // amount we'll check that the server sends us the correct change amount // back to our static address. - err = m.checkChange(ctx, sweepTx, loopIn.AddressParams) + err = m.checkChange(ctx, sweepTx) if err != nil { return err } @@ -369,8 +373,18 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, map[string]*swapserverrpc.ClientSweeplessSigningInfo, len(req.DepositToNonces), ) + depositMap := make(map[string]*deposit.Deposit, len(loopIn.Deposits)) + for _, d := range loopIn.Deposits { + depositMap[d.String()] = d + } for depositOutpoint, nonce := range req.DepositToNonces { + d, ok := depositMap[depositOutpoint] + if !ok { + return fmt.Errorf("deposit %v not found in loop-in", + depositOutpoint) + } + taprootSigHash, err := txscript.CalcTaprootSignatureHash( sigHashes, txscript.SigHashDefault, sweepPacket.UnsignedTx, @@ -387,7 +401,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, copy(serverNonce[:], nonce) musig2Session, err := staticutil.CreateMusig2Session( - ctx, m.cfg.Signer, loopIn.AddressParams, loopIn.Address, + ctx, m.cfg.Signer, d, ) if err != nil { return err @@ -452,7 +466,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, // swaps with identical change outputs. The client needs to ensure that any // swap referenced by the inputs has a respective change output in the batch. func (m *Manager) checkChange(ctx context.Context, - sweepTx *wire.MsgTx, changeAddr *address.Parameters) error { + sweepTx *wire.MsgTx) error { prevOuts := make([]string, len(sweepTx.TxIn)) for i, in := range sweepTx.TxIn { @@ -477,42 +491,67 @@ func (m *Manager) checkChange(ctx context.Context, return err } - var expectedChange btcutil.Amount + var expectedChanges []*wire.TxOut for swapHash := range swapHashes { loopIn, err := m.cfg.Store.GetLoopInByHash(ctx, swapHash) if err != nil { return err } - totalDepositAmount := loopIn.TotalDepositAmount() - changeAmt := totalDepositAmount - loopIn.SelectedAmount - if changeAmt > 0 && changeAmt < totalDepositAmount { - log.Debugf("expected change output to our "+ - "static address, total_deposit_amount=%v, "+ - "selected_amount=%v, "+ - "expected_change_amount=%v ", - totalDepositAmount, loopIn.SelectedAmount, - changeAmt) - - expectedChange += changeAmt + changeAmt := loopIn.ExpectedChangeAmount() + if changeAmt == 0 { + continue + } + + if loopIn.ChangeAddressParams == nil { + return fmt.Errorf("missing change address for swap %x", + swapHash[:]) } + + log.Debugf("expected change output to static address, "+ + "swap_hash=%x, selected_amount=%v, "+ + "expected_change_amount=%v", swapHash[:], + loopIn.SelectedAmount, changeAmt) + + expectedChanges = append(expectedChanges, &wire.TxOut{ + Value: int64(changeAmt), + PkScript: loopIn.ChangeAddressParams.PkScript, + }) } - if expectedChange == 0 { + if len(expectedChanges) == 0 { return nil } - for _, out := range sweepTx.TxOut { - if out.Value == int64(expectedChange) && - bytes.Equal(out.PkScript, changeAddr.PkScript) { + // Match expected change outputs as a multiset. This rejects batched + // transactions that collapse two equal client change outputs into one + // output unless the protocol explicitly negotiates such aggregation. + matchedOutputs := make([]bool, len(sweepTx.TxOut)) + for _, expected := range expectedChanges { + var found bool + for i, out := range sweepTx.TxOut { + if matchedOutputs[i] { + continue + } + + if out.Value == expected.Value && + bytes.Equal(out.PkScript, expected.PkScript) { - // We found the expected change output. - return nil + matchedOutputs[i] = true + found = true + break + } } + + if found { + continue + } + + return fmt.Errorf("couldn't find expected change of %v "+ + "satoshis sent to static address", expected.Value) } - return fmt.Errorf("couldn't find expected change of %v "+ - "satoshis sent to our static address", expectedChange) + return nil } // recover stars a loop-in state machine for each non-final loop-in to pick up @@ -656,19 +695,8 @@ func (m *Manager) initiateLoopIn(ctx context.Context, "deposits: %w", err) } - // TODO(hieblmi): add params to deposit for multi-address - // support. - params, err := m.cfg.AddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, fmt.Errorf("unable to retrieve static "+ - "address parameters: %w", err) - } - selectedDeposits, err = SelectDeposits( - req.SelectedAmount, allDeposits, params.Expiry, - m.currentHeight.Load(), + req.SelectedAmount, allDeposits, m.currentHeight.Load(), ) if err != nil { return nil, fmt.Errorf("unable to select deposits: %w", @@ -759,8 +787,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,20 +880,26 @@ 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) { + unfilteredDeposits []*deposit.Deposit, blockHeight uint32) ( + []*deposit.Deposit, error) { // Filter out deposits that are too close to expiry to be swapped. var deposits []*deposit.Deposit for _, d := range unfilteredDeposits { + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %s", d.OutPoint.String()) + } + if !IsSwappable( - uint32(d.ConfirmationHeight), blockHeight, csvExpiry, + uint32(d.ConfirmationHeight), blockHeight, + d.AddressParams.Expiry, ) { log.Debugf("Skipping deposit %s as it expires before "+ @@ -875,14 +911,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, deposits[i].AddressParams.Expiry, + ) + jExp := blocksUntilDepositExpiry( + uint32(deposits[j].ConfirmationHeight), + blockHeight, deposits[j].AddressParams.Expiry, + ) return iExp < jExp } @@ -914,20 +961,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 +} + +// 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 { - // The htlc expiry height is the current height plus the htlc - // cltv delta. - htlcExpiryHeight := blockHeight + DefaultLoopInOnChainCltvDelta + if confirmationHeight == 0 { + return math.MaxUint32 + } - // Ensure that the deposit doesn't expire before the htlc. - if depositExpiryHeight < htlcExpiryHeight+DepositHtlcDelta { - return false + 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..2d526b182 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}, @@ -162,9 +183,11 @@ func TestSelectDeposits(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + setTestDepositParams(tc.deposits, tc.csvExpiry) + setTestDepositParams(tc.expected, tc.csvExpiry) + selectedDeposits, err := SelectDeposits( - tc.targetValue, tc.deposits, tc.csvExpiry, - tc.blockHeight, + tc.targetValue, tc.deposits, tc.blockHeight, ) if tc.expectedErr == "" { require.NoError(t, err) @@ -176,6 +199,20 @@ func TestSelectDeposits(t *testing.T) { } } +func setTestDepositParams(deposits []*deposit.Deposit, expiry uint32) { + for _, d := range deposits { + d.AddressParams = &address.Parameters{ + Expiry: expiry, + } + } +} + +// 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 @@ -244,6 +281,12 @@ func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) { return false, nil } +func (s *mockStore) RecordStaticAddressRiskDecision(context.Context, + lntypes.Hash, ConfirmationRiskDecision) error { + + return nil +} + func (s *mockStore) GetLoopInByHash(_ context.Context, swapHash lntypes.Hash) (*StaticAddressLoopIn, error) { @@ -322,9 +365,9 @@ func TestCheckChange(t *testing.T) { var hash lntypes.Hash hash[0] = h li := &StaticAddressLoopIn{ - Deposits: deposits, - SelectedAmount: selected, - AddressParams: changeAddr, + Deposits: deposits, + SelectedAmount: selected, + ChangeAddressParams: changeAddr, } return hash, li } @@ -378,7 +421,6 @@ func TestCheckChange(t *testing.T) { name string inDeps []*deposit.Deposit // deposits referenced by tx inputs outputs []*wire.TxOut // outputs in sweep tx - addr *address.Parameters expectErr bool expectedErrMsg string } @@ -394,7 +436,6 @@ func TestCheckChange(t *testing.T) { PkScript: serverAddr.PkScript, }, }, - addr: changeAddr, }, { name: "single swap change present", @@ -409,43 +450,59 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, }, { name: "multiple swaps different change amounts", - inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400)=900 + inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400) outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { - Value: 900, + Value: 500, + PkScript: changeAddr.PkScript, + }, + { + Value: 400, PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, }, { - name: "two swaps with identical change values sum correctly", - inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)=800 + name: "two swaps with identical change values both present", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + }, + }, + { + name: "collapsed identical change output rejected", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) + outputs: []*wire.TxOut{ { Value: 800, PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, + expectErr: true, + expectedErrMsg: "couldn't find expected change", }, { name: "missing change output results in error", inDeps: []*deposit.Deposit{s2d1}, // expect 500 outputs: []*wire.TxOut{}, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -462,7 +519,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -479,7 +535,6 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -500,7 +555,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, }, } @@ -523,7 +577,7 @@ func TestCheckChange(t *testing.T) { mgr.cfg.DepositManager = mdm tx := makeSweepTx(inputs, tc.outputs) - err := mgr.checkChange(ctx, tx, tc.addr) + err := mgr.checkChange(ctx, tx) if tc.expectErr { require.Error(t, err) if tc.expectedErrMsg != "" { diff --git a/staticaddr/loopin/sign_musig_test.go b/staticaddr/loopin/sign_musig_test.go new file mode 100644 index 000000000..c3915f8ba --- /dev/null +++ b/staticaddr/loopin/sign_musig_test.go @@ -0,0 +1,71 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestSignMusig2TxRejectsNonceCountMismatch verifies malformed server nonce +// sets fail cleanly instead of panicking when signing HTLC variants. +func TestSignMusig2TxRejectsNonceCountMismatch(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + network := &chaincfg.RegressionNetParams + staticAddr, err := newStaticAddress( + clientKey.PubKey(), serverKey.PubKey(), 4032, + ) + require.NoError(t, err) + + pkScript, err := staticAddr.StaticAddressScript() + require.NoError(t, err) + + addrParams := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PkScript: pkScript, + Expiry: 4032, + ProtocolVersion: version.ProtocolVersion_V0, + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0xdd}, + Index: 0, + }, + Value: 500_000, + AddressParams: addrParams, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{4, 5, 6}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Deposits: []*deposit.Deposit{dep}, + HtlcTxFeeRate: chainfee.SatPerKWeight(253), + } + + htlcTx, err := loopIn.createHtlcTx(network, loopIn.HtlcTxFeeRate, 1) + require.NoError(t, err) + + _, err = loopIn.signMusig2Tx( + t.Context(), htlcTx, &noopSigner{}, + []*input.MuSig2SessionInfo{{}}, nil, + ) + require.ErrorContains(t, err, "server nonce count") +} diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 1b70bbc48..457233d5a 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -13,6 +13,7 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/clock" @@ -27,6 +28,9 @@ var ( // ErrInvalidOutpoint is returned when an outpoint contains the outpoint // separator. ErrInvalidOutpoint = errors.New("outpoint contains outpoint separator") + + // ErrLoopInNotFound is returned when a loop-in swap is not stored. + ErrLoopInNotFound = errors.New("static address loop-in not found") ) // Querier is the interface that contains all the queries generated by sqlc for @@ -51,6 +55,11 @@ type Querier interface { UpdateStaticAddressLoopIn(ctx context.Context, arg sqlc.UpdateStaticAddressLoopInParams) error + // RecordStaticAddressRiskDecision stores the server's confirmation-risk + // decision for a loop-in swap. + RecordStaticAddressRiskDecision(ctx context.Context, + arg sqlc.RecordStaticAddressRiskDecisionParams) error + // GetStaticAddressLoopInSwap retrieves a loop-in swap by its swap hash. GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (sqlc.GetStaticAddressLoopInSwapRow, error) @@ -203,7 +212,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 { @@ -279,6 +288,17 @@ func (s *SqlStore) CreateLoopIn(ctx context.Context, PaymentTimeoutSeconds: int32(loopIn.PaymentTimeoutSeconds), Fast: loopIn.Fast, } + if loopIn.ChangeAddressParams != nil { + if loopIn.ChangeAddressParams.ID == 0 { + return errors.New("static address change parameters " + + "missing database ID") + } + + staticAddressLoopInParams.ChangeStaticAddressID = sql.NullInt32{ + Int32: loopIn.ChangeAddressParams.ID, + Valid: true, + } + } updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{ SwapHash: loopIn.SwapHash[:], @@ -334,6 +354,11 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, htlcTimeoutSweepTxID = loopIn.HtlcTimeoutSweepTxHash.String() } + var htlcTxID string + if loopIn.HtlcTxHash != nil { + htlcTxID = loopIn.HtlcTxHash.String() + } + updateParams := sqlc.UpdateStaticAddressLoopInParams{ SwapHash: loopIn.SwapHash[:], HtlcTxFeeRateSatKw: int64(loopIn.HtlcTxFeeRate), @@ -341,6 +366,18 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, String: htlcTimeoutSweepTxID, Valid: htlcTimeoutSweepTxID != "", }, + ConfirmedHtlcTxID: sql.NullString{ + String: htlcTxID, + Valid: htlcTxID != "", + }, + ConfirmedHtlcOutputIndex: sql.NullInt32{ + Int32: int32(loopIn.HtlcOutputIndex), + Valid: htlcTxID != "", + }, + ConfirmedHtlcOutputValue: sql.NullInt64{ + Int64: int64(loopIn.HtlcOutputValue), + Valid: htlcTxID != "", + }, } updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{ @@ -361,6 +398,43 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, ) } +// RecordStaticAddressRiskDecision stores the server's confirmation-risk +// decision for a static address loop-in. The timestamp is written by the store +// so recovery can reconstruct the remaining payment deadline from one durable +// clock source. +func (s *SqlStore) RecordStaticAddressRiskDecision(ctx context.Context, + swapHash lntypes.Hash, decision ConfirmationRiskDecision) error { + + if decision != ConfirmationRiskDecisionAccepted && + decision != ConfirmationRiskDecisionRejected { + + return errors.New("unknown confirmation risk decision") + } + + params := sqlc.RecordStaticAddressRiskDecisionParams{ + SwapHash: swapHash[:], + ConfirmationRiskDecision: string(decision), + ConfirmationRiskDecisionTime: sql.NullTime{ + Time: s.clock.Now(), + Valid: true, + }, + } + + return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), + func(q Querier) error { + stored, err := q.IsStored(ctx, swapHash[:]) + if err != nil { + return err + } + if !stored { + return ErrLoopInNotFound + } + + return q.RecordStaticAddressRiskDecision(ctx, params) + }, + ) +} + func (s *SqlStore) BatchUpdateSelectedSwapAmounts(ctx context.Context, updateAmounts map[lntypes.Hash]btcutil.Amount) error { @@ -507,9 +581,22 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } } - depositOutpoints := strings.Split( - swap.DepositOutpoints, OutpointSeparator, - ) + var htlcTxHash *chainhash.Hash + if swap.ConfirmedHtlcTxID.Valid { + htlcTxHash, err = chainhash.NewHashFromStr( + swap.ConfirmedHtlcTxID.String, + ) + if err != nil { + return nil, err + } + } + + var depositOutpoints []string + if swap.DepositOutpoints != "" { + depositOutpoints = strings.Split( + swap.DepositOutpoints, OutpointSeparator, + ) + } timeoutAddressString := swap.HtlcTimeoutSweepAddress var timeoutAddress btcutil.Address @@ -530,7 +617,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, return nil, err } - sqlcDeposit := sqlc.Deposit{ + sqlcDeposit := sqlc.AllDepositsRow{ DepositID: id[:], TxHash: d.TxHash, Amount: d.Amount, @@ -539,6 +626,16 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, TimeoutSweepPkScript: d.TimeoutSweepPkScript, ExpirySweepTxid: d.ExpirySweepTxid, FinalizedWithdrawalTx: d.FinalizedWithdrawalTx, + SwapHash: d.SwapHash, + StaticAddressID: d.StaticAddressID, + ClientPubkey: d.ClientPubkey, + ServerPubkey: d.ServerPubkey, + Expiry: d.Expiry, + ClientKeyFamily: d.ClientKeyFamily, + ClientKeyIndex: d.ClientKeyIndex, + Pkscript: d.Pkscript, + ProtocolVersion: d.ProtocolVersion, + InitiationHeight: d.InitiationHeight, } sqlcDepositUpdate := sqlc.DepositUpdate{ @@ -556,6 +653,11 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, depositList = append(depositList, deposit) } + changeAddressParams, err := toChangeAddressParameters(swap) + if err != nil { + return nil, err + } + loopIn := &StaticAddressLoopIn{ SwapHash: swapHash, SwapPreimage: swapPreImage, @@ -580,12 +682,25 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, DepositOutpoints: depositOutpoints, SelectedAmount: btcutil.Amount(swap.SelectedAmount), Fast: swap.Fast, + ConfirmationRiskDecision: ConfirmationRiskDecision( + swap.ConfirmationRiskDecision, + ), HtlcTxFeeRate: chainfee.SatPerKWeight( swap.HtlcTxFeeRateSatKw, ), HtlcTimeoutSweepAddress: timeoutAddress, HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash, - Deposits: depositList, + HtlcTxHash: htlcTxHash, + HtlcOutputIndex: uint32(swap.ConfirmedHtlcOutputIndex.Int32), + HtlcOutputValue: btcutil.Amount( + swap.ConfirmedHtlcOutputValue.Int64, + ), + Deposits: depositList, + ChangeAddressParams: changeAddressParams, + } + if swap.ConfirmationRiskDecisionTime.Valid { + loopIn.ConfirmationRiskDecisionTime = + swap.ConfirmationRiskDecisionTime.Time } if len(updates) > 0 { @@ -595,3 +710,41 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, return loopIn, nil } + +// toChangeAddressParameters converts the optional joined static address row +// into the change address parameters used to verify batched sweepless sweeps. +func toChangeAddressParameters(row sqlc.GetStaticAddressLoopInSwapRow) ( + *address.Parameters, error) { + + if !row.ChangeStaticAddressID.Valid { + return nil, nil + } + + clientKey, err := btcec.ParsePubKey(row.ChangeClientPubkey) + if err != nil { + return nil, err + } + + serverKey, err := btcec.ParsePubKey(row.ChangeServerPubkey) + if err != nil { + return nil, err + } + + return &address.Parameters{ + ID: row.ChangeStaticAddressID.Int32, + ClientPubkey: clientKey, + ServerPubkey: serverKey, + Expiry: uint32(row.ChangeExpiry.Int32), + PkScript: row.ChangePkscript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + row.ChangeClientKeyFamily.Int32, + ), + Index: uint32(row.ChangeClientKeyIndex.Int32), + }, + ProtocolVersion: version.AddressProtocolVersion( + row.ChangeProtocolVersion.Int32, + ), + InitiationHeight: row.ChangeInitiationHeight.Int32, + }, nil +} diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 356049bc7..711ff28c8 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,185 @@ 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.Equal( + t, ConfirmationRiskDecisionNone, + storedSwap.ConfirmationRiskDecision, + ) + + decisionTime := time.Unix(123, 0).UTC() + testClock.SetTime(decisionTime) + err = swapStore.RecordStaticAddressRiskDecision( + ctxb, swapHashPending, ConfirmationRiskDecisionAccepted, + ) + require.NoError(t, err) + + storedSwap, err = swapStore.GetLoopInByHash(ctxb, swapHashPending) + require.NoError(t, err) + require.Equal( + t, ConfirmationRiskDecisionAccepted, + storedSwap.ConfirmationRiskDecision, + ) + require.True(t, storedSwap.ConfirmationRiskDecisionTime.Equal(decisionTime)) + + err = swapStore.RecordStaticAddressRiskDecision( + ctxb, lntypes.Hash{0x9, 0x9, 0x9}, + ConfirmationRiskDecisionRejected, + ) + require.ErrorIs(t, err, ErrLoopInNotFound) + + require.Len(t, storedSwap.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, 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()) +} + +func TestUpdateLoopInPersistsConfirmedHtlcOutpoint(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) + + d := &deposit.Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, + Index: 0, + }, + 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{0x4, 0x2, 0x3, 0x5} + swap := StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x4, 0x2, 0x3, 0x5}, + DepositOutpoints: []string{d.OutPoint.String()}, + Deposits: []*deposit.Deposit{d}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + swap.SetState(MonitorInvoiceAndHtlcTx) + require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap)) - require.Len(t, swap.Deposits, 2) + confirmedHtlcTxHash := chainhash.Hash{0x55} + swap.HtlcTxHash = &confirmedHtlcTxHash + swap.HtlcOutputIndex = 2 + swap.HtlcOutputValue = 88_000 + require.NoError(t, swapStore.UpdateLoopIn(ctxb, &swap)) - 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()) + storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash) + require.NoError(t, err) + require.NotNil(t, storedSwap.HtlcTxHash) + require.Equal(t, confirmedHtlcTxHash, *storedSwap.HtlcTxHash) + require.EqualValues(t, 2, storedSwap.HtlcOutputIndex) + require.EqualValues(t, 88_000, storedSwap.HtlcOutputValue) + require.Equal(t, MonitorInvoiceAndHtlcTx, storedSwap.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, + ) - 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()) + 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/staticutil/utils.go b/staticaddr/staticutil/utils.go index 7ef33201b..a047068ad 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -13,7 +13,6 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" @@ -22,18 +21,27 @@ import ( ) // ToPrevOuts converts a slice of deposits to a map of outpoints to TxOuts. -func ToPrevOuts(deposits []*deposit.Deposit, - pkScript []byte) (map[wire.OutPoint]*wire.TxOut, error) { +// +// Each deposit carries the static address parameters that produced its output. +// Using the per-deposit script here keeps signing correct when one transaction +// spends deposits from multiple static addresses. +func ToPrevOuts(deposits []*deposit.Deposit) ( + map[wire.OutPoint]*wire.TxOut, error) { prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(deposits)) for _, d := range deposits { + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for deposit %v", d.OutPoint) + } + outpoint := wire.OutPoint{ Hash: d.Hash, Index: d.Index, } txOut := &wire.TxOut{ Value: int64(d.Value), - PkScript: pkScript, + PkScript: d.AddressParams.PkScript, } if _, ok := prevOuts[outpoint]; ok { return nil, fmt.Errorf("duplicate outpoint %v", @@ -45,11 +53,70 @@ func ToPrevOuts(deposits []*deposit.Deposit, return prevOuts, nil } +// DepositClientPubkeys maps each deposit outpoint to the client static address +// pubkey that derives that output. +// +// The server receives this proof material with swap and withdrawal requests and +// verifies it against the L402's server key and expiry before co-signing any +// input. +func DepositClientPubkeys(deposits []*deposit.Deposit) ( + map[string][]byte, error) { + + clientPubkeys := make(map[string][]byte, len(deposits)) + for _, d := range deposits { + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for deposit %v", d.OutPoint) + } + if d.AddressParams.ClientPubkey == nil { + return nil, fmt.Errorf("missing static address client "+ + "pubkey for deposit %v", d.OutPoint) + } + + depositKey := d.String() + if _, ok := clientPubkeys[depositKey]; ok { + return nil, fmt.Errorf("duplicate outpoint %v", + depositKey) + } + + clientPubkeys[depositKey] = + d.AddressParams.ClientPubkey.SerializeCompressed() + } + + return clientPubkeys, nil +} + +// ChangeOutput converts a locally generated static address into the RPC change +// descriptor sent to the server. The descriptor binds the expected script, +// amount and client key so the server can derive and verify the same address. +func ChangeOutput(params *address.Parameters, + amount btcutil.Amount) (*swapserverrpc.StaticAddressChangeOutput, error) { + + if amount <= 0 { + return nil, nil + } + if params == nil { + return nil, fmt.Errorf("missing static address change parameters") + } + if params.ClientPubkey == nil { + return nil, fmt.Errorf("missing static address change client " + + "pubkey") + } + if len(params.PkScript) == 0 { + return nil, fmt.Errorf("missing static address change pkscript") + } + + return &swapserverrpc.StaticAddressChangeOutput{ + ClientPubkey: params.ClientPubkey.SerializeCompressed(), + PkScript: params.PkScript, + Amount: int64(amount), + }, nil +} + // CreateMusig2Sessions creates a musig2 session for a number of deposits. func CreateMusig2Sessions(ctx context.Context, - signer lndclient.SignerClient, deposits []*deposit.Deposit, - addrParams *address.Parameters, - staticAddress *script.StaticAddress) ([]*input.MuSig2SessionInfo, + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( + []*input.MuSig2SessionInfo, [][]byte, error) { musig2Sessions := make([]*input.MuSig2SessionInfo, len(deposits)) @@ -58,7 +125,7 @@ func CreateMusig2Sessions(ctx context.Context, // Create the sessions and nonces from the deposits. for i := range len(deposits) { session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, + ctx, signer, deposits[i], ) if err != nil { return nil, nil, err @@ -72,11 +139,12 @@ func CreateMusig2Sessions(ctx context.Context, } // CreateMusig2SessionsPerDeposit creates a musig2 session for a number of -// deposits. +// deposits and returns the sessions keyed by outpoint string. +// +// The per-deposit keying mirrors the server response format and avoids relying +// on positional ordering after the request crosses the wire. func CreateMusig2SessionsPerDeposit(ctx context.Context, - signer lndclient.SignerClient, deposits []*deposit.Deposit, - addrParams *address.Parameters, - staticAddress *script.StaticAddress) ( + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( map[string]*input.MuSig2SessionInfo, map[string][]byte, map[string]int, error) { @@ -86,25 +154,44 @@ func CreateMusig2SessionsPerDeposit(ctx context.Context, // Create the musig2 sessions for the sweepless sweep tx. for i, deposit := range deposits { + depositKey := deposit.String() + if _, ok := sessions[depositKey]; ok { + return nil, nil, nil, fmt.Errorf("duplicate outpoint "+ + "%v", depositKey) + } + session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, + ctx, signer, deposit, ) if err != nil { return nil, nil, nil, err } - sessions[deposit.String()] = session - nonces[deposit.String()] = session.PublicNonce[:] - depositToIdx[deposit.String()] = i + sessions[depositKey] = session + nonces[depositKey] = session.PublicNonce[:] + depositToIdx[depositKey] = i } return sessions, nonces, depositToIdx, nil } -// CreateMusig2Session creates a musig2 session for the deposit. +// CreateMusig2Session creates a musig2 session for the deposit's static +// address. func CreateMusig2Session(ctx context.Context, - signer lndclient.SignerClient, addrParams *address.Parameters, - staticAddress *script.StaticAddress) (*input.MuSig2SessionInfo, error) { + signer lndclient.SignerClient, d *deposit.Deposit) ( + *input.MuSig2SessionInfo, error) { + + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %v", d.OutPoint) + } + + staticAddress, err := d.GetStaticAddressScript() + if err != nil { + return nil, err + } + + addrParams := d.AddressParams signers := [][]byte{ addrParams.ClientPubkey.SerializeCompressed(), diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index 0694f8c84..94dbdc2d6 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -11,7 +11,6 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" looptest "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" @@ -37,7 +36,8 @@ func TestToPrevOuts_Success(t *testing.T) { Hash: mustHash(t, "0000000000000000000000000000000000000000000000000000000000000001"), Index: 0, }, - Value: btcutil.Amount(12345), + Value: btcutil.Amount(12345), + AddressParams: &address.Parameters{PkScript: []byte{0x51}}, } d2 := &deposit.Deposit{ @@ -45,12 +45,11 @@ func TestToPrevOuts_Success(t *testing.T) { Hash: mustHash(t, "1111111111111111111111111111111111111111111111111111111111111111"), Index: 7, }, - Value: btcutil.Amount(987654321), + Value: btcutil.Amount(987654321), + AddressParams: &address.Parameters{PkScript: []byte{0x52}}, } - pkScript := []byte{0x51, 0x21, 0x02, 0x52} // arbitrary bytes - - prevOuts, err := ToPrevOuts([]*deposit.Deposit{d1, d2}, pkScript) + prevOuts, err := ToPrevOuts([]*deposit.Deposit{d1, d2}) require.NoError(t, err) // We expect two entries. @@ -60,13 +59,13 @@ func TestToPrevOuts_Success(t *testing.T) { txOut1, ok := prevOuts[d1.OutPoint] require.True(t, ok, "expected outpoint d1 to be present") require.EqualValues(t, int64(d1.Value), txOut1.Value) - require.Equal(t, pkScript, txOut1.PkScript) + require.Equal(t, d1.AddressParams.PkScript, txOut1.PkScript) // Check the second outpoint mapping. txOut2, ok := prevOuts[d2.OutPoint] require.True(t, ok, "expected outpoint d2 to be present") require.EqualValues(t, int64(d2.Value), txOut2.Value) - require.Equal(t, pkScript, txOut2.PkScript) + require.Equal(t, d2.AddressParams.PkScript, txOut2.PkScript) // Ensure the keys in the map are exactly the outpoints we provided. for op := range prevOuts { @@ -81,13 +80,144 @@ func TestToPrevOuts_DuplicateOutpoint(t *testing.T) { Index: 2, } - d1 := &deposit.Deposit{OutPoint: shared, Value: btcutil.Amount(100)} - d2 := &deposit.Deposit{OutPoint: shared, Value: btcutil.Amount(200)} + d1 := &deposit.Deposit{ + OutPoint: shared, + Value: btcutil.Amount(100), + AddressParams: &address.Parameters{PkScript: []byte{0x00}}, + } + d2 := &deposit.Deposit{ + OutPoint: shared, + Value: btcutil.Amount(200), + AddressParams: &address.Parameters{PkScript: []byte{0x01}}, + } - _, err := ToPrevOuts([]*deposit.Deposit{d1, d2}, []byte{0x00}) + _, err := ToPrevOuts([]*deposit.Deposit{d1, d2}) require.Error(t, err) } +func TestToPrevOutsMissingAddressParams(t *testing.T) { + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "3333333333333333333333333333333333333333333333333333333333333333"), + Index: 3, + }, + Value: btcutil.Amount(100), + } + + _, err := ToPrevOuts([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address parameters") +} + +func TestDepositClientPubkeys(t *testing.T) { + clientKey1, err := btcec.NewPrivateKey() + require.NoError(t, err) + clientKey2, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d1 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "4444444444444444444444444444444444444444444444444444444444444444"), + Index: 0, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey1.PubKey(), + }, + } + d2 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "5555555555555555555555555555555555555555555555555555555555555555"), + Index: 1, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey2.PubKey(), + }, + } + + proofs, err := DepositClientPubkeys([]*deposit.Deposit{d1, d2}) + require.NoError(t, err) + require.Equal( + t, clientKey1.PubKey().SerializeCompressed(), + proofs[d1.String()], + ) + require.Equal( + t, clientKey2.PubKey().SerializeCompressed(), + proofs[d2.String()], + ) +} + +func TestDepositClientPubkeysRejectsInvalidDeposits(t *testing.T) { + t.Run("missing params", func(t *testing.T) { + d := &deposit.Deposit{OutPoint: wire.OutPoint{Index: 1}} + _, err := DepositClientPubkeys([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address parameters") + }) + + t.Run("missing client key", func(t *testing.T) { + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: &address.Parameters{}, + } + _, err := DepositClientPubkeys([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address client pubkey") + }) + + t.Run("duplicate outpoint", func(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "6666666666666666666666666666666666666666666666666666666666666666"), + Index: 1, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, + } + _, err = DepositClientPubkeys([]*deposit.Deposit{d, d}) + require.ErrorContains(t, err, "duplicate outpoint") + }) +} + +func TestChangeOutput(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + PkScript: []byte{0x51, 0x20, 0x01}, + } + amount := btcutil.Amount(12345) + + changeOutput, err := ChangeOutput(params, amount) + require.NoError(t, err) + require.Equal( + t, clientKey.PubKey().SerializeCompressed(), + changeOutput.ClientPubkey, + ) + require.Equal(t, params.PkScript, changeOutput.PkScript) + require.EqualValues(t, amount, changeOutput.Amount) + + changeOutput, err = ChangeOutput(params, 0) + require.NoError(t, err) + require.Nil(t, changeOutput) +} + +func TestChangeOutputRejectsInvalidParams(t *testing.T) { + _, err := ChangeOutput(nil, 100) + require.ErrorContains(t, err, "missing static address change parameters") + + _, err = ChangeOutput(&address.Parameters{}, 100) + require.ErrorContains(t, err, "missing static address change client pubkey") + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + _, err = ChangeOutput(&address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, 100) + require.ErrorContains(t, err, "missing static address change pkscript") +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash { @@ -183,13 +313,8 @@ func TestCreateMusig2Session_Success(t *testing.T) { KeyLocator: keychain.KeyLocator{Family: 1, Index: 2}, } - // Build a static address for tweak options. - staticAddr, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), params.ClientPubkey, params.ServerPubkey, - ) - require.NoError(t, err) - - sess, err := CreateMusig2Session(context.Background(), signer, params, staticAddr) + d := &deposit.Deposit{AddressParams: params} + sess, err := CreateMusig2Session(context.Background(), signer, d) require.NoError(t, err) require.NotNil(t, sess) } @@ -212,20 +337,15 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, } - staticAddr, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), params.ClientPubkey, params.ServerPubkey, - ) - require.NoError(t, err) - // Prepare N deposits; only the length matters for session count. deposits := []*deposit.Deposit{ - {OutPoint: wire.OutPoint{Index: 0}}, - {OutPoint: wire.OutPoint{Index: 1}}, - {OutPoint: wire.OutPoint{Index: 2}}, + {OutPoint: wire.OutPoint{Index: 0}, AddressParams: params}, + {OutPoint: wire.OutPoint{Index: 1}, AddressParams: params}, + {OutPoint: wire.OutPoint{Index: 2}, AddressParams: params}, } sessions, nonces, err := CreateMusig2Sessions( - context.Background(), signer, deposits, params, staticAddr, + context.Background(), signer, deposits, ) require.NoError(t, err) require.Len(t, sessions, len(deposits)) diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index da79cf5a0..71267b024 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -19,6 +19,10 @@ type AddressManager interface { // GetStaticAddress returns the deposit address for the given // client and server public keys. GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) + + // NewChangeAddress derives and persists a fresh static address from the + // change key family for this operation's change output. + NewChangeAddress(ctx context.Context) (*address.Parameters, error) } type DepositManager interface { diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 99fddd267..181ac3a8f 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -9,7 +9,6 @@ import ( "sync" "sync/atomic" - "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" @@ -19,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" staticaddressrpc "github.com/lightninglabs/loop/swapserverrpc" @@ -281,7 +281,6 @@ func (m *Manager) recoverWithdrawals(ctx context.Context) error { err = m.handleWithdrawal( ctx, deposits, tx.TxHash(), - tx.TxOut[0].PkScript, ) if err != nil { return err @@ -381,6 +380,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 @@ -437,12 +445,6 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, return "", "", nil } - withdrawalPkScript, err := txscript.PayToAddrScript(withdrawalAddress) - if err != nil { - return "", "", fmt.Errorf("could not get withdrawal "+ - "pkscript: %w", err) - } - // If this is the first time this cluster of deposits is withdrawn, we // start a goroutine that listens for the spent of the first input of // the withdrawal transaction. @@ -458,7 +460,7 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, } err = m.handleWithdrawal( - ctx, deposits, finalizedTx.TxHash(), withdrawalPkScript, + ctx, deposits, finalizedTx.TxHash(), ) if err != nil { return "", "", err @@ -525,40 +527,61 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, selectedWithdrawalAmount int64, commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { - // Create a musig2 session for each deposit. - addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) + // Create a musig2 session for each deposit. Each selected deposit carries + // the address parameters that produced the output, so withdrawals can + // spend inputs from multiple static addresses in one transaction. + sessions, clientNonces, idx, err := staticutil.CreateMusig2SessionsPerDeposit( + ctx, m.cfg.Signer, deposits, + ) if err != nil { return nil, nil, err } - staticAddress, err := m.cfg.AddressManager.GetStaticAddress(ctx) + outpoints := toOutpoints(deposits) + prevOuts, err := staticutil.ToPrevOuts(deposits) if err != nil { return nil, nil, err } - sessions, clientNonces, idx, err := staticutil.CreateMusig2SessionsPerDeposit( - ctx, m.cfg.Signer, deposits, addrParams, staticAddress, - ) + depositClientPubkeys, err := staticutil.DepositClientPubkeys(deposits) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("unable to prepare static address "+ + "input proofs: %w", err) } - params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) + _, changeAmount, err := CalculateWithdrawalTxValues( + deposits, btcutil.Amount(selectedWithdrawalAmount), feeRate, + withdrawalAddress, commitmentType, + ) if err != nil { - return nil, nil, fmt.Errorf("couldn't get confirmation "+ - "height for deposit, %w", err) + return nil, nil, fmt.Errorf("error calculating funding tx "+ + "values: %w", err) } - outpoints := toOutpoints(deposits) - prevOuts, err := staticutil.ToPrevOuts(deposits, params.PkScript) - if err != nil { - return nil, nil, err + var ( + changeParams *address.Parameters + changeOutput *staticaddressrpc.StaticAddressChangeOutput + ) + if changeAmount > 0 { + changeParams, err = m.cfg.AddressManager.NewChangeAddress(ctx) + if err != nil { + return nil, nil, fmt.Errorf("unable to create static "+ + "address change output: %w", err) + } + + changeOutput, err = staticutil.ChangeOutput( + changeParams, changeAmount, + ) + if err != nil { + return nil, nil, fmt.Errorf("unable to prepare static "+ + "address change output: %w", err) + } } withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx( - ctx, outpoints, deposits, prevOuts, + outpoints, deposits, prevOuts, btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress, - feeRate, commitmentType, + feeRate, commitmentType, changeParams, ) if err != nil { return nil, nil, err @@ -573,8 +596,10 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, // nolint:lll sigResp, err := m.cfg.StaticAddressServerClient.ServerPsbtWithdrawDeposits( ctx, &staticaddressrpc.ServerPsbtWithdrawRequest{ - WithdrawalPsbt: unsignedPsbt, - DepositToNonces: clientNonces, + WithdrawalPsbt: unsignedPsbt, + DepositToNonces: clientNonces, + DepositToClientPubkeys: depositClientPubkeys, + ChangeOutput: changeOutput, }, ) if err != nil { @@ -653,22 +678,28 @@ func (m *Manager) publishFinalizedWithdrawalTx(ctx context.Context, return true, nil } +func withdrawalChangePkScript(tx *wire.MsgTx) []byte { + if tx == nil || len(tx.TxOut) < 2 { + return nil + } + + return tx.TxOut[1].PkScript +} + // handleWithdrawal starts a goroutine that listens for the spent of the first // input of the withdrawal transaction. func (m *Manager) handleWithdrawal(ctx context.Context, - deposits []*deposit.Deposit, txHash chainhash.Hash, - withdrawalPkscript []byte) error { - - addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - log.Errorf("error retrieving address params: %v", err) + deposits []*deposit.Deposit, originalTxHash chainhash.Hash) error { - return fmt.Errorf("withdrawal failed") + d := deposits[0] + if d.AddressParams == nil { + return fmt.Errorf("missing static address parameters for %v", + d.OutPoint) } + depositPkScript := d.AddressParams.PkScript - d := deposits[0] spentChan, errChan, err := m.cfg.ChainNotifier.RegisterSpendNtfn( - ctx, &d.OutPoint, addrParams.PkScript, + ctx, &d.OutPoint, depositPkScript, int32(d.ConfirmationHeight), ) if err != nil { @@ -679,13 +710,19 @@ func (m *Manager) handleWithdrawal(ctx context.Context, select { case spentTx := <-spentChan: spendingHeight := uint32(spentTx.SpendingHeight) + spenderTxHash := originalTxHash + if spentTx.SpenderTxHash != nil { + spenderTxHash = *spentTx.SpenderTxHash + } else if spentTx.SpendingTx != nil { + spenderTxHash = spentTx.SpendingTx.TxHash() + } + // If the transaction received one confirmation, we // ensure re-org safety by waiting for some more // confirmations. confChan, confErrChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, spentTx.SpenderTxHash, - withdrawalPkscript, MinConfs, + ctx, &spenderTxHash, nil, MinConfs, int32(m.initiationHeight.Load()), ) if err != nil { @@ -699,6 +736,18 @@ func (m *Manager) handleWithdrawal(ctx context.Context, select { case tx := <-confChan: + confirmedTx := spentTx.SpendingTx + if tx != nil && tx.Tx != nil { + confirmedTx = tx.Tx + } + if confirmedTx == nil { + log.Errorf("Confirmed withdrawal %v "+ + "missing transaction", + spenderTxHash) + + return + } + err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, deposit.Withdrawn, @@ -712,13 +761,14 @@ func (m *Manager) handleWithdrawal(ctx context.Context, // withdrawals to stop republishing it on block // arrivals. m.mu.Lock() - delete(m.finalizedWithdrawalTxns, txHash) + delete(m.finalizedWithdrawalTxns, originalTxHash) + delete(m.finalizedWithdrawalTxns, spenderTxHash) m.mu.Unlock() // Persist info about the finalized withdrawal. err = m.cfg.Store.UpdateWithdrawal( - ctx, deposits, tx.Tx, spendingHeight, - addrParams.PkScript, + ctx, deposits, confirmedTx, spendingHeight, + withdrawalChangePkScript(confirmedTx), ) if err != nil { log.Errorf("Error persisting "+ @@ -856,12 +906,13 @@ func (m *Manager) signMusig2Tx(ctx context.Context, return tx, nil } -func (m *Manager) createWithdrawalTx(ctx context.Context, +func (m *Manager) createWithdrawalTx( outpoints []wire.OutPoint, deposits []*deposit.Deposit, prevOuts map[wire.OutPoint]*wire.TxOut, selectedWithdrawalAmount btcutil.Amount, withdrawAddr btcutil.Address, feeRate chainfee.SatPerKWeight, - commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { + commitmentType lnrpc.CommitmentType, + changeParams *address.Parameters) (*wire.MsgTx, []byte, error) { // First Create the tx. msgTx := wire.NewMsgTx(2) @@ -908,30 +959,14 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, }) if changeAmount > 0 { - // Send change back to the same static address. - staticAddress, err := m.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - log.Errorf("error retrieving taproot address %v", err) - - return nil, nil, fmt.Errorf("withdrawal failed") - } - - changeAddress, err := btcutil.NewAddressTaproot( - schnorr.SerializePubKey(staticAddress.TaprootKey), - m.cfg.ChainParams, - ) - if err != nil { - return nil, nil, err - } - - changeScript, err := txscript.PayToAddrScript(changeAddress) - if err != nil { - return nil, nil, err + if changeParams == nil { + return nil, nil, fmt.Errorf("missing static address " + + "change parameters") } msgTx.AddTxOut(&wire.TxOut{ Value: int64(changeAmount), - PkScript: changeScript, + PkScript: changeParams.PkScript, }) } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 4ffd4e1e8..e1d6e5d9d 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -3,15 +3,20 @@ package withdraw import ( "context" "testing" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" @@ -34,6 +39,124 @@ func TestNewManagerHeightValidation(t *testing.T) { require.NotNil(t, manager) } +func TestWithdrawalChangePkScript(t *testing.T) { + t.Parallel() + + require.Nil(t, withdrawalChangePkScript(nil)) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: []byte{0x01}, + }) + require.Nil(t, withdrawalChangePkScript(tx)) + + tx.AddTxOut(&wire.TxOut{ + Value: 500, + PkScript: []byte{0x02}, + }) + require.Equal(t, []byte{0x02}, withdrawalChangePkScript(tx)) +} + +type withdrawalConfRegistration struct { + txID *chainhash.Hash + pkScript []byte + numConfs int32 + heightHint int32 +} + +type withdrawalTestNotifier struct { + lndclient.ChainNotifierClient + + spendChan chan *chainntnfs.SpendDetail + spendErr chan error + confChan chan *chainntnfs.TxConfirmation + confErr chan error + confReq chan withdrawalConfRegistration +} + +func newWithdrawalTestNotifier() *withdrawalTestNotifier { + return &withdrawalTestNotifier{ + spendChan: make(chan *chainntnfs.SpendDetail, 1), + spendErr: make(chan error, 1), + confChan: make(chan *chainntnfs.TxConfirmation, 1), + confErr: make(chan error, 1), + confReq: make(chan withdrawalConfRegistration, 1), + } +} + +func (n *withdrawalTestNotifier) RegisterSpendNtfn(context.Context, + *wire.OutPoint, []byte, int32, ...lndclient.NotifierOption) ( + chan *chainntnfs.SpendDetail, chan error, error) { + + return n.spendChan, n.spendErr, nil +} + +func (n *withdrawalTestNotifier) RegisterConfirmationsNtfn(_ context.Context, + txid *chainhash.Hash, pkScript []byte, numConfs, heightHint int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.TxConfirmation, + chan error, error) { + + n.confReq <- withdrawalConfRegistration{ + txID: txid, + pkScript: pkScript, + numConfs: numConfs, + heightHint: heightHint, + } + + return n.confChan, n.confErr, nil +} + +func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + UseLogger(btclog.Disabled) + + notifier := newWithdrawalTestNotifier() + manager, err := NewManager(&ManagerConfig{ + ChainNotifier: notifier, + }, 123) + require.NoError(t, err) + + originalTxHash := chainhash.Hash{1} + replacementTxHash := chainhash.Hash{2} + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + }, + ConfirmationHeight: 42, + AddressParams: &address.Parameters{ + PkScript: []byte{0x51}, + }, + } + manager.finalizedWithdrawalTxns[originalTxHash] = wire.NewMsgTx(2) + + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{dep}, originalTxHash, + ) + require.NoError(t, err) + + notifier.spendChan <- &chainntnfs.SpendDetail{ + SpenderTxHash: &replacementTxHash, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 50, + } + + select { + case req := <-notifier.confReq: + require.NotNil(t, req.txID) + require.Equal(t, replacementTxHash, *req.txID) + require.Nil(t, req.pkScript) + require.Equal(t, MinConfs, req.numConfs) + require.EqualValues(t, 123, req.heightHint) + + case <-ctx.Done(): + t.Fatalf("confirmation registration not received: %v", ctx.Err()) + } +} + // TestSignMusig2Tx_MissingSigningInfo tests that signMusig2Tx should error // when sigInfo is missing an entry for one of the deposits. // 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/swapserverrpc/staticaddr.pb.go b/swapserverrpc/staticaddr.pb.go index 77ad9799e..b7c7e3ef8 100644 --- a/swapserverrpc/staticaddr.pb.go +++ b/swapserverrpc/staticaddr.pb.go @@ -387,8 +387,15 @@ type ServerPsbtWithdrawRequest struct { WithdrawalPsbt []byte `protobuf:"bytes,1,opt,name=withdrawal_psbt,json=withdrawalPsbt,proto3" json:"withdrawal_psbt,omitempty"` // The map of deposit txid:idx to the nonce used by the client. DepositToNonces map[string][]byte `protobuf:"bytes,2,rep,name=deposit_to_nonces,json=depositToNonces,proto3" json:"deposit_to_nonces,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + DepositToClientPubkeys map[string][]byte `protobuf:"bytes,3,rep,name=deposit_to_client_pubkeys,json=depositToClientPubkeys,proto3" json:"deposit_to_client_pubkeys,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional change output metadata for withdrawals that return funds to a + // newly generated static address. + ChangeOutput *StaticAddressChangeOutput `protobuf:"bytes,4,opt,name=change_output,json=changeOutput,proto3" json:"change_output,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ServerPsbtWithdrawRequest) Reset() { @@ -435,6 +442,20 @@ func (x *ServerPsbtWithdrawRequest) GetDepositToNonces() map[string][]byte { return nil } +func (x *ServerPsbtWithdrawRequest) GetDepositToClientPubkeys() map[string][]byte { + if x != nil { + return x.DepositToClientPubkeys + } + return nil +} + +func (x *ServerPsbtWithdrawRequest) GetChangeOutput() *StaticAddressChangeOutput { + if x != nil { + return x.ChangeOutput + } + return nil +} + type ServerPsbtWithdrawResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The txid of the psbt that the client wants to push the sigs for. @@ -544,6 +565,69 @@ func (x *ServerPsbtWithdrawSigningInfo) GetSig() []byte { return nil } +type StaticAddressChangeOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The client static address pubkey used to derive the change output. + ClientPubkey []byte `protobuf:"bytes,1,opt,name=client_pubkey,json=clientPubkey,proto3" json:"client_pubkey,omitempty"` + // The expected output script for the static address change output. + PkScript []byte `protobuf:"bytes,2,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` + // The expected change amount in satoshis. + Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StaticAddressChangeOutput) Reset() { + *x = StaticAddressChangeOutput{} + mi := &file_staticaddr_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StaticAddressChangeOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StaticAddressChangeOutput) ProtoMessage() {} + +func (x *StaticAddressChangeOutput) ProtoReflect() protoreflect.Message { + mi := &file_staticaddr_proto_msgTypes[8] + 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 StaticAddressChangeOutput.ProtoReflect.Descriptor instead. +func (*StaticAddressChangeOutput) Descriptor() ([]byte, []int) { + return file_staticaddr_proto_rawDescGZIP(), []int{8} +} + +func (x *StaticAddressChangeOutput) GetClientPubkey() []byte { + if x != nil { + return x.ClientPubkey + } + return nil +} + +func (x *StaticAddressChangeOutput) GetPkScript() []byte { + if x != nil { + return x.PkScript + } + return nil +} + +func (x *StaticAddressChangeOutput) GetAmount() int64 { + if x != nil { + return x.Amount + } + return 0 +} + type ServerStaticAddressLoopInRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The client's public key for the htlc output. @@ -589,14 +673,21 @@ type ServerStaticAddressLoopInRequest struct { Amount uint64 `protobuf:"varint,9,opt,name=amount,proto3" json:"amount,omitempty"` // If set, request the server to use fast publication behavior for this // swap. - Fast bool `protobuf:"varint,10,opt,name=fast,proto3" json:"fast,omitempty"` + Fast bool `protobuf:"varint,10,opt,name=fast,proto3" json:"fast,omitempty"` + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + DepositToClientPubkeys map[string][]byte `protobuf:"bytes,11,rep,name=deposit_to_client_pubkeys,json=depositToClientPubkeys,proto3" json:"deposit_to_client_pubkeys,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional change output metadata for fractional loop-ins that return + // funds to a newly generated static address. + ChangeOutput *StaticAddressChangeOutput `protobuf:"bytes,12,opt,name=change_output,json=changeOutput,proto3" json:"change_output,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerStaticAddressLoopInRequest) Reset() { *x = ServerStaticAddressLoopInRequest{} - mi := &file_staticaddr_proto_msgTypes[8] + mi := &file_staticaddr_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -608,7 +699,7 @@ func (x *ServerStaticAddressLoopInRequest) String() string { func (*ServerStaticAddressLoopInRequest) ProtoMessage() {} func (x *ServerStaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[8] + mi := &file_staticaddr_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -621,7 +712,7 @@ func (x *ServerStaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerStaticAddressLoopInRequest.ProtoReflect.Descriptor instead. func (*ServerStaticAddressLoopInRequest) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{8} + return file_staticaddr_proto_rawDescGZIP(), []int{9} } func (x *ServerStaticAddressLoopInRequest) GetHtlcClientPubKey() []byte { @@ -694,6 +785,20 @@ func (x *ServerStaticAddressLoopInRequest) GetFast() bool { return false } +func (x *ServerStaticAddressLoopInRequest) GetDepositToClientPubkeys() map[string][]byte { + if x != nil { + return x.DepositToClientPubkeys + } + return nil +} + +func (x *ServerStaticAddressLoopInRequest) GetChangeOutput() *StaticAddressChangeOutput { + if x != nil { + return x.ChangeOutput + } + return nil +} + type ServerStaticAddressLoopInResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The server's public key for the htlc output. @@ -715,7 +820,7 @@ type ServerStaticAddressLoopInResponse struct { func (x *ServerStaticAddressLoopInResponse) Reset() { *x = ServerStaticAddressLoopInResponse{} - mi := &file_staticaddr_proto_msgTypes[9] + mi := &file_staticaddr_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -727,7 +832,7 @@ func (x *ServerStaticAddressLoopInResponse) String() string { func (*ServerStaticAddressLoopInResponse) ProtoMessage() {} func (x *ServerStaticAddressLoopInResponse) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[9] + mi := &file_staticaddr_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -740,7 +845,7 @@ func (x *ServerStaticAddressLoopInResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ServerStaticAddressLoopInResponse.ProtoReflect.Descriptor instead. func (*ServerStaticAddressLoopInResponse) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{9} + return file_staticaddr_proto_rawDescGZIP(), []int{10} } func (x *ServerStaticAddressLoopInResponse) GetHtlcServerPubKey() []byte { @@ -790,7 +895,7 @@ type ServerHtlcSigningInfo struct { func (x *ServerHtlcSigningInfo) Reset() { *x = ServerHtlcSigningInfo{} - mi := &file_staticaddr_proto_msgTypes[10] + mi := &file_staticaddr_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -802,7 +907,7 @@ func (x *ServerHtlcSigningInfo) String() string { func (*ServerHtlcSigningInfo) ProtoMessage() {} func (x *ServerHtlcSigningInfo) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[10] + mi := &file_staticaddr_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -815,7 +920,7 @@ func (x *ServerHtlcSigningInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerHtlcSigningInfo.ProtoReflect.Descriptor instead. func (*ServerHtlcSigningInfo) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{10} + return file_staticaddr_proto_rawDescGZIP(), []int{11} } func (x *ServerHtlcSigningInfo) GetNonces() [][]byte { @@ -848,7 +953,7 @@ type PushStaticAddressHtlcSigsRequest struct { func (x *PushStaticAddressHtlcSigsRequest) Reset() { *x = PushStaticAddressHtlcSigsRequest{} - mi := &file_staticaddr_proto_msgTypes[11] + mi := &file_staticaddr_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -860,7 +965,7 @@ func (x *PushStaticAddressHtlcSigsRequest) String() string { func (*PushStaticAddressHtlcSigsRequest) ProtoMessage() {} func (x *PushStaticAddressHtlcSigsRequest) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[11] + mi := &file_staticaddr_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -873,7 +978,7 @@ func (x *PushStaticAddressHtlcSigsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushStaticAddressHtlcSigsRequest.ProtoReflect.Descriptor instead. func (*PushStaticAddressHtlcSigsRequest) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{11} + return file_staticaddr_proto_rawDescGZIP(), []int{12} } func (x *PushStaticAddressHtlcSigsRequest) GetSwapHash() []byte { @@ -916,7 +1021,7 @@ type ClientHtlcSigningInfo struct { func (x *ClientHtlcSigningInfo) Reset() { *x = ClientHtlcSigningInfo{} - mi := &file_staticaddr_proto_msgTypes[12] + mi := &file_staticaddr_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -928,7 +1033,7 @@ func (x *ClientHtlcSigningInfo) String() string { func (*ClientHtlcSigningInfo) ProtoMessage() {} func (x *ClientHtlcSigningInfo) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[12] + mi := &file_staticaddr_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -941,7 +1046,7 @@ func (x *ClientHtlcSigningInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientHtlcSigningInfo.ProtoReflect.Descriptor instead. func (*ClientHtlcSigningInfo) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{12} + return file_staticaddr_proto_rawDescGZIP(), []int{13} } func (x *ClientHtlcSigningInfo) GetNonces() [][]byte { @@ -966,7 +1071,7 @@ type PushStaticAddressHtlcSigsResponse struct { func (x *PushStaticAddressHtlcSigsResponse) Reset() { *x = PushStaticAddressHtlcSigsResponse{} - mi := &file_staticaddr_proto_msgTypes[13] + mi := &file_staticaddr_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -978,7 +1083,7 @@ func (x *PushStaticAddressHtlcSigsResponse) String() string { func (*PushStaticAddressHtlcSigsResponse) ProtoMessage() {} func (x *PushStaticAddressHtlcSigsResponse) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[13] + mi := &file_staticaddr_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -991,7 +1096,7 @@ func (x *PushStaticAddressHtlcSigsResponse) ProtoReflect() protoreflect.Message // Deprecated: Use PushStaticAddressHtlcSigsResponse.ProtoReflect.Descriptor instead. func (*PushStaticAddressHtlcSigsResponse) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{13} + return file_staticaddr_proto_rawDescGZIP(), []int{14} } type PushStaticAddressSweeplessSigsRequest struct { @@ -1013,7 +1118,7 @@ type PushStaticAddressSweeplessSigsRequest struct { func (x *PushStaticAddressSweeplessSigsRequest) Reset() { *x = PushStaticAddressSweeplessSigsRequest{} - mi := &file_staticaddr_proto_msgTypes[14] + mi := &file_staticaddr_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1025,7 +1130,7 @@ func (x *PushStaticAddressSweeplessSigsRequest) String() string { func (*PushStaticAddressSweeplessSigsRequest) ProtoMessage() {} func (x *PushStaticAddressSweeplessSigsRequest) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[14] + mi := &file_staticaddr_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1038,7 +1143,7 @@ func (x *PushStaticAddressSweeplessSigsRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use PushStaticAddressSweeplessSigsRequest.ProtoReflect.Descriptor instead. func (*PushStaticAddressSweeplessSigsRequest) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{14} + return file_staticaddr_proto_rawDescGZIP(), []int{15} } func (x *PushStaticAddressSweeplessSigsRequest) GetSwapHash() []byte { @@ -1082,7 +1187,7 @@ type ClientSweeplessSigningInfo struct { func (x *ClientSweeplessSigningInfo) Reset() { *x = ClientSweeplessSigningInfo{} - mi := &file_staticaddr_proto_msgTypes[15] + mi := &file_staticaddr_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1094,7 +1199,7 @@ func (x *ClientSweeplessSigningInfo) String() string { func (*ClientSweeplessSigningInfo) ProtoMessage() {} func (x *ClientSweeplessSigningInfo) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[15] + mi := &file_staticaddr_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1107,7 +1212,7 @@ func (x *ClientSweeplessSigningInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientSweeplessSigningInfo.ProtoReflect.Descriptor instead. func (*ClientSweeplessSigningInfo) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{15} + return file_staticaddr_proto_rawDescGZIP(), []int{16} } func (x *ClientSweeplessSigningInfo) GetNonce() []byte { @@ -1132,7 +1237,7 @@ type PushStaticAddressSweeplessSigsResponse struct { func (x *PushStaticAddressSweeplessSigsResponse) Reset() { *x = PushStaticAddressSweeplessSigsResponse{} - mi := &file_staticaddr_proto_msgTypes[16] + mi := &file_staticaddr_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1144,7 +1249,7 @@ func (x *PushStaticAddressSweeplessSigsResponse) String() string { func (*PushStaticAddressSweeplessSigsResponse) ProtoMessage() {} func (x *PushStaticAddressSweeplessSigsResponse) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[16] + mi := &file_staticaddr_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1157,7 +1262,7 @@ func (x *PushStaticAddressSweeplessSigsResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use PushStaticAddressSweeplessSigsResponse.ProtoReflect.Descriptor instead. func (*PushStaticAddressSweeplessSigsResponse) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{16} + return file_staticaddr_proto_rawDescGZIP(), []int{17} } var File_staticaddr_proto protoreflect.FileDescriptor @@ -1184,12 +1289,17 @@ const file_staticaddr_proto_rawDesc = "" + "\rchange_amount\x18\x06 \x01(\x03R\fchangeAmount:\x02\x18\x01\"m\n" + "\x16ServerWithdrawResponse\x12*\n" + "\x11musig2_sweep_sigs\x18\x01 \x03(\fR\x0fmusig2SweepSigs\x12#\n" + - "\rserver_nonces\x18\x02 \x03(\fR\fserverNonces:\x02\x18\x01\"\xed\x01\n" + + "\rserver_nonces\x18\x02 \x03(\fR\fserverNonces:\x02\x18\x01\"\xfc\x03\n" + "\x19ServerPsbtWithdrawRequest\x12'\n" + "\x0fwithdrawal_psbt\x18\x01 \x01(\fR\x0ewithdrawalPsbt\x12c\n" + - "\x11deposit_to_nonces\x18\x02 \x03(\v27.looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntryR\x0fdepositToNonces\x1aB\n" + + "\x11deposit_to_nonces\x18\x02 \x03(\v27.looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntryR\x0fdepositToNonces\x12y\n" + + "\x19deposit_to_client_pubkeys\x18\x03 \x03(\v2>.looprpc.ServerPsbtWithdrawRequest.DepositToClientPubkeysEntryR\x16depositToClientPubkeys\x12G\n" + + "\rchange_output\x18\x04 \x01(\v2\".looprpc.StaticAddressChangeOutputR\fchangeOutput\x1aB\n" + "\x14DepositToNoncesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\x1aI\n" + + "\x1bDepositToClientPubkeysEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\xf1\x01\n" + "\x1aServerPsbtWithdrawResponse\x12\x12\n" + "\x04txid\x18\x01 \x01(\fR\x04txid\x12W\n" + @@ -1199,7 +1309,11 @@ const file_staticaddr_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\v2&.looprpc.ServerPsbtWithdrawSigningInfoR\x05value:\x028\x01\"G\n" + "\x1dServerPsbtWithdrawSigningInfo\x12\x14\n" + "\x05nonce\x18\x01 \x01(\fR\x05nonce\x12\x10\n" + - "\x03sig\x18\x02 \x01(\fR\x03sig\"\xae\x03\n" + + "\x03sig\x18\x02 \x01(\fR\x03sig\"u\n" + + "\x19StaticAddressChangeOutput\x12#\n" + + "\rclient_pubkey\x18\x01 \x01(\fR\fclientPubkey\x12\x1b\n" + + "\tpk_script\x18\x02 \x01(\fR\bpkScript\x12\x16\n" + + "\x06amount\x18\x03 \x01(\x03R\x06amount\"\xc5\x05\n" + " ServerStaticAddressLoopInRequest\x12-\n" + "\x13htlc_client_pub_key\x18\x01 \x01(\fR\x10htlcClientPubKey\x12\x1b\n" + "\tswap_hash\x18\x02 \x01(\fR\bswapHash\x12+\n" + @@ -1212,7 +1326,12 @@ const file_staticaddr_proto_rawDesc = "" + "\x17payment_timeout_seconds\x18\b \x01(\rR\x15paymentTimeoutSeconds\x12\x16\n" + "\x06amount\x18\t \x01(\x04R\x06amount\x12\x12\n" + "\x04fast\x18\n" + - " \x01(\bR\x04fast\"\xe1\x02\n" + + " \x01(\bR\x04fast\x12\x80\x01\n" + + "\x19deposit_to_client_pubkeys\x18\v \x03(\v2E.looprpc.ServerStaticAddressLoopInRequest.DepositToClientPubkeysEntryR\x16depositToClientPubkeys\x12G\n" + + "\rchange_output\x18\f \x01(\v2\".looprpc.StaticAddressChangeOutputR\fchangeOutput\x1aI\n" + + "\x1bDepositToClientPubkeysEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\xe1\x02\n" + "!ServerStaticAddressLoopInResponse\x12-\n" + "\x13htlc_server_pub_key\x18\x01 \x01(\fR\x10htlcServerPubKey\x12\x1f\n" + "\vhtlc_expiry\x18\x02 \x01(\x05R\n" + @@ -1267,7 +1386,7 @@ func file_staticaddr_proto_rawDescGZIP() []byte { } var file_staticaddr_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_staticaddr_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_staticaddr_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_staticaddr_proto_goTypes = []any{ (StaticAddressProtocolVersion)(0), // 0: looprpc.StaticAddressProtocolVersion (*ServerNewAddressRequest)(nil), // 1: looprpc.ServerNewAddressRequest @@ -1278,53 +1397,60 @@ var file_staticaddr_proto_goTypes = []any{ (*ServerPsbtWithdrawRequest)(nil), // 6: looprpc.ServerPsbtWithdrawRequest (*ServerPsbtWithdrawResponse)(nil), // 7: looprpc.ServerPsbtWithdrawResponse (*ServerPsbtWithdrawSigningInfo)(nil), // 8: looprpc.ServerPsbtWithdrawSigningInfo - (*ServerStaticAddressLoopInRequest)(nil), // 9: looprpc.ServerStaticAddressLoopInRequest - (*ServerStaticAddressLoopInResponse)(nil), // 10: looprpc.ServerStaticAddressLoopInResponse - (*ServerHtlcSigningInfo)(nil), // 11: looprpc.ServerHtlcSigningInfo - (*PushStaticAddressHtlcSigsRequest)(nil), // 12: looprpc.PushStaticAddressHtlcSigsRequest - (*ClientHtlcSigningInfo)(nil), // 13: looprpc.ClientHtlcSigningInfo - (*PushStaticAddressHtlcSigsResponse)(nil), // 14: looprpc.PushStaticAddressHtlcSigsResponse - (*PushStaticAddressSweeplessSigsRequest)(nil), // 15: looprpc.PushStaticAddressSweeplessSigsRequest - (*ClientSweeplessSigningInfo)(nil), // 16: looprpc.ClientSweeplessSigningInfo - (*PushStaticAddressSweeplessSigsResponse)(nil), // 17: looprpc.PushStaticAddressSweeplessSigsResponse - nil, // 18: looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry - nil, // 19: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry - nil, // 20: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry - (*PrevoutInfo)(nil), // 21: looprpc.PrevoutInfo + (*StaticAddressChangeOutput)(nil), // 9: looprpc.StaticAddressChangeOutput + (*ServerStaticAddressLoopInRequest)(nil), // 10: looprpc.ServerStaticAddressLoopInRequest + (*ServerStaticAddressLoopInResponse)(nil), // 11: looprpc.ServerStaticAddressLoopInResponse + (*ServerHtlcSigningInfo)(nil), // 12: looprpc.ServerHtlcSigningInfo + (*PushStaticAddressHtlcSigsRequest)(nil), // 13: looprpc.PushStaticAddressHtlcSigsRequest + (*ClientHtlcSigningInfo)(nil), // 14: looprpc.ClientHtlcSigningInfo + (*PushStaticAddressHtlcSigsResponse)(nil), // 15: looprpc.PushStaticAddressHtlcSigsResponse + (*PushStaticAddressSweeplessSigsRequest)(nil), // 16: looprpc.PushStaticAddressSweeplessSigsRequest + (*ClientSweeplessSigningInfo)(nil), // 17: looprpc.ClientSweeplessSigningInfo + (*PushStaticAddressSweeplessSigsResponse)(nil), // 18: looprpc.PushStaticAddressSweeplessSigsResponse + nil, // 19: looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry + nil, // 20: looprpc.ServerPsbtWithdrawRequest.DepositToClientPubkeysEntry + nil, // 21: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry + nil, // 22: looprpc.ServerStaticAddressLoopInRequest.DepositToClientPubkeysEntry + nil, // 23: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry + (*PrevoutInfo)(nil), // 24: looprpc.PrevoutInfo } var file_staticaddr_proto_depIdxs = []int32{ 0, // 0: looprpc.ServerNewAddressRequest.protocol_version:type_name -> looprpc.StaticAddressProtocolVersion 3, // 1: looprpc.ServerNewAddressResponse.params:type_name -> looprpc.ServerAddressParameters - 21, // 2: looprpc.ServerWithdrawRequest.outpoints:type_name -> looprpc.PrevoutInfo - 18, // 3: looprpc.ServerPsbtWithdrawRequest.deposit_to_nonces:type_name -> looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry - 19, // 4: looprpc.ServerPsbtWithdrawResponse.signing_info:type_name -> looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry - 0, // 5: looprpc.ServerStaticAddressLoopInRequest.protocol_version:type_name -> looprpc.StaticAddressProtocolVersion - 11, // 6: looprpc.ServerStaticAddressLoopInResponse.standard_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo - 11, // 7: looprpc.ServerStaticAddressLoopInResponse.high_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo - 11, // 8: looprpc.ServerStaticAddressLoopInResponse.extreme_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo - 13, // 9: looprpc.PushStaticAddressHtlcSigsRequest.standard_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo - 13, // 10: looprpc.PushStaticAddressHtlcSigsRequest.high_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo - 13, // 11: looprpc.PushStaticAddressHtlcSigsRequest.extreme_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo - 20, // 12: looprpc.PushStaticAddressSweeplessSigsRequest.signing_info:type_name -> looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry - 8, // 13: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry.value:type_name -> looprpc.ServerPsbtWithdrawSigningInfo - 16, // 14: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry.value:type_name -> looprpc.ClientSweeplessSigningInfo - 1, // 15: looprpc.StaticAddressServer.ServerNewAddress:input_type -> looprpc.ServerNewAddressRequest - 4, // 16: looprpc.StaticAddressServer.ServerWithdrawDeposits:input_type -> looprpc.ServerWithdrawRequest - 6, // 17: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:input_type -> looprpc.ServerPsbtWithdrawRequest - 9, // 18: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:input_type -> looprpc.ServerStaticAddressLoopInRequest - 12, // 19: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:input_type -> looprpc.PushStaticAddressHtlcSigsRequest - 15, // 20: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:input_type -> looprpc.PushStaticAddressSweeplessSigsRequest - 2, // 21: looprpc.StaticAddressServer.ServerNewAddress:output_type -> looprpc.ServerNewAddressResponse - 5, // 22: looprpc.StaticAddressServer.ServerWithdrawDeposits:output_type -> looprpc.ServerWithdrawResponse - 7, // 23: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:output_type -> looprpc.ServerPsbtWithdrawResponse - 10, // 24: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:output_type -> looprpc.ServerStaticAddressLoopInResponse - 14, // 25: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:output_type -> looprpc.PushStaticAddressHtlcSigsResponse - 17, // 26: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:output_type -> looprpc.PushStaticAddressSweeplessSigsResponse - 21, // [21:27] is the sub-list for method output_type - 15, // [15:21] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 24, // 2: looprpc.ServerWithdrawRequest.outpoints:type_name -> looprpc.PrevoutInfo + 19, // 3: looprpc.ServerPsbtWithdrawRequest.deposit_to_nonces:type_name -> looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry + 20, // 4: looprpc.ServerPsbtWithdrawRequest.deposit_to_client_pubkeys:type_name -> looprpc.ServerPsbtWithdrawRequest.DepositToClientPubkeysEntry + 9, // 5: looprpc.ServerPsbtWithdrawRequest.change_output:type_name -> looprpc.StaticAddressChangeOutput + 21, // 6: looprpc.ServerPsbtWithdrawResponse.signing_info:type_name -> looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry + 0, // 7: looprpc.ServerStaticAddressLoopInRequest.protocol_version:type_name -> looprpc.StaticAddressProtocolVersion + 22, // 8: looprpc.ServerStaticAddressLoopInRequest.deposit_to_client_pubkeys:type_name -> looprpc.ServerStaticAddressLoopInRequest.DepositToClientPubkeysEntry + 9, // 9: looprpc.ServerStaticAddressLoopInRequest.change_output:type_name -> looprpc.StaticAddressChangeOutput + 12, // 10: looprpc.ServerStaticAddressLoopInResponse.standard_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo + 12, // 11: looprpc.ServerStaticAddressLoopInResponse.high_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo + 12, // 12: looprpc.ServerStaticAddressLoopInResponse.extreme_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo + 14, // 13: looprpc.PushStaticAddressHtlcSigsRequest.standard_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo + 14, // 14: looprpc.PushStaticAddressHtlcSigsRequest.high_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo + 14, // 15: looprpc.PushStaticAddressHtlcSigsRequest.extreme_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo + 23, // 16: looprpc.PushStaticAddressSweeplessSigsRequest.signing_info:type_name -> looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry + 8, // 17: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry.value:type_name -> looprpc.ServerPsbtWithdrawSigningInfo + 17, // 18: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry.value:type_name -> looprpc.ClientSweeplessSigningInfo + 1, // 19: looprpc.StaticAddressServer.ServerNewAddress:input_type -> looprpc.ServerNewAddressRequest + 4, // 20: looprpc.StaticAddressServer.ServerWithdrawDeposits:input_type -> looprpc.ServerWithdrawRequest + 6, // 21: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:input_type -> looprpc.ServerPsbtWithdrawRequest + 10, // 22: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:input_type -> looprpc.ServerStaticAddressLoopInRequest + 13, // 23: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:input_type -> looprpc.PushStaticAddressHtlcSigsRequest + 16, // 24: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:input_type -> looprpc.PushStaticAddressSweeplessSigsRequest + 2, // 25: looprpc.StaticAddressServer.ServerNewAddress:output_type -> looprpc.ServerNewAddressResponse + 5, // 26: looprpc.StaticAddressServer.ServerWithdrawDeposits:output_type -> looprpc.ServerWithdrawResponse + 7, // 27: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:output_type -> looprpc.ServerPsbtWithdrawResponse + 11, // 28: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:output_type -> looprpc.ServerStaticAddressLoopInResponse + 15, // 29: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:output_type -> looprpc.PushStaticAddressHtlcSigsResponse + 18, // 30: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:output_type -> looprpc.PushStaticAddressSweeplessSigsResponse + 25, // [25:31] is the sub-list for method output_type + 19, // [19:25] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_staticaddr_proto_init() } @@ -1339,7 +1465,7 @@ func file_staticaddr_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_staticaddr_proto_rawDesc), len(file_staticaddr_proto_rawDesc)), NumEnums: 1, - NumMessages: 20, + NumMessages: 23, NumExtensions: 0, NumServices: 1, }, diff --git a/swapserverrpc/staticaddr.proto b/swapserverrpc/staticaddr.proto index b590f4d44..0ae80edfd 100644 --- a/swapserverrpc/staticaddr.proto +++ b/swapserverrpc/staticaddr.proto @@ -124,6 +124,15 @@ message ServerPsbtWithdrawRequest { // The map of deposit txid:idx to the nonce used by the client. map deposit_to_nonces = 2; + + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + map deposit_to_client_pubkeys = 3; + + // Optional change output metadata for withdrawals that return funds to a + // newly generated static address. + StaticAddressChangeOutput change_output = 4; } message ServerPsbtWithdrawResponse { @@ -143,6 +152,17 @@ message ServerPsbtWithdrawSigningInfo { bytes sig = 2; } +message StaticAddressChangeOutput { + // The client static address pubkey used to derive the change output. + bytes client_pubkey = 1; + + // The expected output script for the static address change output. + bytes pk_script = 2; + + // The expected change amount in satoshis. + int64 amount = 3; +} + message ServerStaticAddressLoopInRequest { // The client's public key for the htlc output. bytes htlc_client_pub_key = 1; @@ -196,6 +216,15 @@ message ServerStaticAddressLoopInRequest { // If set, request the server to use fast publication behavior for this // swap. bool fast = 10; + + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + map deposit_to_client_pubkeys = 11; + + // Optional change output metadata for fractional loop-ins that return + // funds to a newly generated static address. + StaticAddressChangeOutput change_output = 12; } message ServerStaticAddressLoopInResponse { 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 }