diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index c973a0028..b78d3e646 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -4,11 +4,14 @@ import ( "context" "errors" "fmt" + "math" + "os" "sort" "strings" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd" @@ -17,6 +20,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/routing/route" "github.com/urfave/cli/v3" + "golang.org/x/term" ) func init() { @@ -29,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, @@ -45,15 +50,99 @@ 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) @@ -82,6 +171,186 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error { 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() + + 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 = lnd.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"}, @@ -846,6 +1115,24 @@ func lowConfDepositWarning(allDeposits []*looprpc.Deposit, ) } +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 " + ".loop under your home directory will take your ability to " + diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go index 2cc88ad66..71aafa4c0 100644 --- a/cmd/loop/staticaddr_test.go +++ b/cmd/loop/staticaddr_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "strings" "testing" @@ -8,11 +9,39 @@ import ( "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) +} + // TestLowConfDepositWarningConfirmedOnly verifies confirmed deposits below the // conservative warning threshold are included in the warning text. func TestLowConfDepositWarningConfirmedOnly(t *testing.T) { @@ -196,6 +225,9 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { OutPoint: outpoint, Value: btcutil.Amount(fixture.value), ConfirmationHeight: fixture.confirmationHeight, + AddressParams: &address.Parameters{ + Expiry: csvExpiry, + }, }) } @@ -204,8 +236,7 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { ) loopInSelected, err := loopin.SelectDeposits( - btcutil.Amount(targetAmount), loopInDeposits, csvExpiry, - blockHeight, + btcutil.Amount(targetAmount), loopInDeposits, blockHeight, ) require.NoError(t, err) diff --git a/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json b/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json index 99186eb26..5876bff6d 100644 --- a/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json +++ b/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json @@ -65,6 +65,7 @@ " \"id\": \"bb7f050df0b7c3e1fe61010e10ad45e30ddf7acd301fa6e05a2ddb825b5c2efb\",\n", " \"outpoint\": \"56cd081a3a6eadf25b7d3fe0b61207389352ed69a622d2ec28c5d669bf6a5313:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json b/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json index 79c024585..ab221508b 100644 --- a/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json +++ b/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json @@ -92,6 +92,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -101,6 +102,7 @@ " \"id\": \"86b5e2cdf9694c8e7398e42afde109766d7cd2142203905ba63fbd0eb1370ef3\",\n", " \"outpoint\": \"bb358e4f73ae97c4e2d99c6d64e852bba7cf56e13105b05d1200b8ae1796665e:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -110,6 +112,7 @@ " \"id\": \"6c290f7536ea5097946afffac6a69906a26d775823ebbacedfe6f2d69c0745e4\",\n", " \"outpoint\": \"5eaa7dd7a291665393eddf5dece91feef901f22665933cce7a0732a9b81c3001:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -119,6 +122,7 @@ " \"id\": \"0182b4d895b1c467290ae7b5c6c42ff76b2a4225807a94211c973170d5a883eb\",\n", " \"outpoint\": \"7e6360d6e6a394cfd096adf0bfe1275c5a83541eb573e90e463a78dc715f8894:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json b/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json index e935ed5eb..92d14aa1a 100644 --- a/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json +++ b/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json @@ -65,6 +65,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPED_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json b/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json index 407287ffd..6dbf9009d 100644 --- a/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json +++ b/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json @@ -110,6 +110,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -119,6 +120,7 @@ " \"id\": \"86b5e2cdf9694c8e7398e42afde109766d7cd2142203905ba63fbd0eb1370ef3\",\n", " \"outpoint\": \"bb358e4f73ae97c4e2d99c6d64e852bba7cf56e13105b05d1200b8ae1796665e:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -128,6 +130,7 @@ " \"id\": \"6c290f7536ea5097946afffac6a69906a26d775823ebbacedfe6f2d69c0745e4\",\n", " \"outpoint\": \"5eaa7dd7a291665393eddf5dece91feef901f22665933cce7a0732a9b81c3001:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -137,6 +140,7 @@ " \"id\": \"0182b4d895b1c467290ae7b5c6c42ff76b2a4225807a94211c973170d5a883eb\",\n", " \"outpoint\": \"7e6360d6e6a394cfd096adf0bfe1275c5a83541eb573e90e463a78dc715f8894:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -146,6 +150,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPED_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n", " \"value\": \"500000\"\n", " },\n", @@ -155,6 +160,7 @@ " \"id\": \"bb7f050df0b7c3e1fe61010e10ad45e30ddf7acd301fa6e05a2ddb825b5c2efb\",\n", " \"outpoint\": \"56cd081a3a6eadf25b7d3fe0b61207389352ed69a622d2ec28c5d669bf6a5313:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json b/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json index 1cb79c441..12689a556 100644 --- a/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json +++ b/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json @@ -77,6 +77,7 @@ " \"id\": \"7a7cbe9b90f23d47aa92eb10a9d323f7ace6e9eaab5b77379c63422c15da19c8\",\n", " \"outpoint\": \"0e70673c1da3343648c26f779555346f30d235314838b1160826d0d5c29b4fba:1\",\n", " \"state\": \"CHANNEL_PUBLISHED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -86,6 +87,7 @@ " \"id\": \"ff9a43b2082f906a2e2758934220c4ce32393eb2823b292517ae081e16daded9\",\n", " \"outpoint\": \"d2d6e50f157f0d31b8688a4af4f064edf3454714e92369b2c8c4d82477edbaca:0\",\n", " \"state\": \"CHANNEL_PUBLISHED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"1000000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json b/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json index 69a7ab554..e196eaa41 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json +++ b/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json @@ -40,7 +40,8 @@ "event": "request", "message_type": "looprpc.NewStaticAddressRequest", "payload": { - "client_key": "" + "client_key": "", + "send_coins_request": null } } }, @@ -64,7 +65,8 @@ "lines": [ "{\n", " \"address\": \"bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw\",\n", - " \"expiry\": 14400\n", + " \"expiry\": 14400,\n", + " \"send_coins_response\": null\n", "}\n" ] } diff --git a/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json b/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json index 2b5335b75..322f930c1 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json +++ b/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json @@ -25,6 +25,7 @@ "\n", "COMMANDS:\n", " new, n Create a new static loop in address.\n", + " deposit Create and fund a new static loop in address.\n", " listunspent, l List unspent static address outputs.\n", " listdeposits Displays static address deposits. A filter can be applied to only show deposits in a specific state.\n", " listwithdrawals Display a summary of past withdrawals.\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json b/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json index ff4d0d14d..e22efed83 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json +++ b/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json @@ -61,6 +61,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"DEPOSITED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json index 5acb8ddf0..49c6a9ef1 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json +++ b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json @@ -228,6 +228,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json index b997322b0..666c2a7ea 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json +++ b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json @@ -209,6 +209,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json index 02b5a3ac4..690c48d2f 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json +++ b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json @@ -244,6 +244,7 @@ " \"id\": \"82771323e95dca403d966f70a88be39ef0a475ef6aa78694044ba9b87304ac63\",\n", " \"outpoint\": \"da52bf383c4fe5c684221c311fc5756ccaee211b6c6e6f5ccc159622a6039271:1\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json index f2994fde2..69ff4b8ed 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json +++ b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json @@ -235,6 +235,7 @@ " \"id\": \"d8a58536d8472873b9e2e1657468328360fda0b94231cfc5e29900cab735da84\",\n", " \"outpoint\": \"f2280f0f086273be73bde92fd9b982208338a5ecebbe93b83b00c77c4d2f8d1b:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"550000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json b/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json index 3093fb9d6..37aae5a7a 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json +++ b/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json @@ -159,6 +159,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json b/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json index dae0c0a05..d254a4508 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json +++ b/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json @@ -214,6 +214,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json b/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json index 15b5d4e1f..211fc7a8f 100644 --- a/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json +++ b/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json @@ -73,6 +73,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/docs/loop.1 b/docs/loop.1 index b100a83be..e608a61ea 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -427,6 +427,39 @@ Create a new static loop in address. .PP \fB--help, -h\fP: show help +.SS deposit +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 List unspent static address outputs. diff --git a/docs/loop.md b/docs/loop.md index 688d3776d..f366ec515 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -532,7 +532,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: @@ -546,6 +546,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 d910e3d50..469283669 100644 --- a/go.mod +++ b/go.mod @@ -182,7 +182,7 @@ require ( golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.44.0 // indirect diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d89dbbce2..06f94aa11 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -38,6 +38,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 +47,7 @@ import ( "github.com/lightningnetwork/lnd/zpay32" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) const ( @@ -960,24 +963,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", @@ -1710,20 +1702,169 @@ 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 } + 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), + 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, + 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: //nolint:staticcheck + 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): //nolint:staticcheck + + 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: //nolint:staticcheck + 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) ( @@ -1731,7 +1872,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 { @@ -1782,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), @@ -1895,7 +2050,10 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, f := func(d *deposit.Deposit) bool { return slices.Contains(outpoints, d.OutPoint.String()) } - filteredDeposits = filter(allDeposits, f) + filteredDeposits, err = s.filterDeposits(allDeposits, f) + if err != nil { + return nil, err + } if len(outpoints) != len(filteredDeposits) { return nil, fmt.Errorf("not all outpoints found in " + @@ -1911,11 +2069,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) } @@ -1993,13 +2154,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) @@ -2040,22 +2194,23 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, if ds, ok := depositsBySwap[swp.SwapHash]; ok { protoDeposits = make([]*looprpc.Deposit, 0, len(ds)) for _, d := range ds { - state := toClientDepositState(d.GetState()) confirmationHeight := d.GetConfirmationHeight() + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static "+ + "address parameters for deposit %v", + d.OutPoint) + } blocksUntilExpiry := depositBlocksUntilExpiry( - confirmationHeight, addrParams.Expiry, + confirmationHeight, + d.AddressParams.Expiry, int64(lndInfo.BlockHeight), ) - pd := &looprpc.Deposit{ - Id: d.ID[:], - State: state, - Outpoint: d.OutPoint.String(), - Value: int64(d.Value), - ConfirmationHeight: confirmationHeight, - SwapHash: d.SwapHash[:], - BlocksUntilExpiry: blocksUntilExpiry, + pd, err := s.rpcDeposit(d) + if err != nil { + return nil, err } + pd.BlocksUntilExpiry = blocksUntilExpiry protoDeposits = append(protoDeposits, pd) } } @@ -2237,11 +2392,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) } @@ -2283,21 +2441,31 @@ 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) { + expiry, ok := expiryByOutpoint[deposits[i].Outpoint] + if !ok { + continue + } + deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry( - deposits[i].ConfirmationHeight, params.Expiry, + deposits[i].ConfirmationHeight, expiry, bestBlockHeight, ) } @@ -2346,35 +2514,65 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context, type filterFunc func(deposits *deposit.Deposit) bool -func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit { +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.GetConfirmationHeight(), - 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.GetConfirmationHeight(), + 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_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index bb4cc01ca..012bcf104 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -2,6 +2,7 @@ package loopd import ( "context" + "strings" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -14,6 +15,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" mock_lnd "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" ) @@ -62,12 +64,44 @@ func (s *staticAddrDepositStore) AllDeposits(context.Context) ( return s.allDeposits, nil } -type staticAddrTestAddressManager struct{} +type staticAddrTestAddressManager struct { + params *address.Parameters +} + +func newStaticAddrTestAddressManager() *staticAddrTestAddressManager { + _, client := mock_lnd.CreateKey(1) + _, server := mock_lnd.CreateKey(2) + + return &staticAddrTestAddressManager{ + params: &address.Parameters{ + ID: 1, + ClientPubkey: client, + ServerPubkey: server, + Expiry: 10, + PkScript: []byte("pkscript"), + }, + } +} func (s *staticAddrTestAddressManager) GetStaticAddressParameters( context.Context) (*script.Parameters, error) { - return nil, nil + return s.params, nil +} + +func (s *staticAddrTestAddressManager) GetStaticAddressID( + context.Context, []byte) (int32, error) { + + return s.params.ID, nil +} + +func (s *staticAddrTestAddressManager) GetParameters( + pkScript []byte) *address.Parameters { + + params := *s.params + params.PkScript = pkScript + + return ¶ms } func (s *staticAddrTestAddressManager) GetStaticAddress( @@ -99,7 +133,7 @@ func newTestDepositManager( } return deposit.NewManager(&deposit.ManagerConfig{ - AddressManager: &staticAddrTestAddressManager{}, + AddressManager: newStaticAddrTestAddressManager(), Store: &staticAddrDepositStore{ allDeposits: deposits, byOutpoint: byOutpoint, @@ -136,6 +170,161 @@ func newTestStaticAddressContext(t *testing.T) (*address.Manager, 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") +} + // TestListStaticAddressDepositsReturnsVisibleDeposits verifies normal deposit // listings include visible deposit records. func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { @@ -150,6 +339,17 @@ func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { 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(available), staticAddressManager: addrMgr, @@ -165,6 +365,10 @@ func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { t, available.OutPoint.String(), resp.FilteredDeposits[0].Outpoint, ) + require.Equal( + t, expectedAddr.String(), + resp.FilteredDeposits[0].StaticAddress, + ) } // TestGetStaticAddressSummaryTotalsDeposits verifies visible deposits are diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index f3fab0328..55ad2424e 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -1,7 +1,9 @@ package loopd import ( + "bytes" "context" + "database/sql" "fmt" "os" "testing" @@ -25,6 +27,7 @@ import ( "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/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" @@ -411,6 +414,17 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { } testDeposit.SetState(deposit.LoopedIn) + _, clientPubkey := mock_lnd.CreateKey(1) + _, serverPubkey := mock_lnd.CreateKey(2) + staticAddressParams := &script.Parameters{ + ID: 1, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: staticAddressExpiry, + PkScript: []byte("pkscript"), + } + testDeposit.AddressParams = staticAddressParams + initiationTime := time.Unix(1_234, 567).UTC() lastUpdateTime := time.Unix(2_345, 678).UTC() staticLoopIn := &loopin.StaticAddressLoopIn{ @@ -441,15 +455,8 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { }, 1) require.NoError(t, err) - _, clientPubkey := mock_lnd.CreateKey(1) - _, serverPubkey := mock_lnd.CreateKey(2) addrStore := &mockAddressStore{ - params: []*script.Parameters{{ - ClientPubkey: clientPubkey, - ServerPubkey: serverPubkey, - Expiry: staticAddressExpiry, - PkScript: []byte("pkscript"), - }}, + params: []*script.Parameters{staticAddressParams}, } addrMgr, err := address.NewManager(&address.ManagerConfig{ Store: addrStore, @@ -1281,10 +1288,25 @@ type mockAddressStore struct { func (s *mockAddressStore) CreateStaticAddress(_ context.Context, p *script.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) ( *script.Parameters, error) { @@ -1301,6 +1323,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 @@ -1436,7 +1468,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 := &script.Parameters{ ClientPubkey: client, ServerPubkey: server, @@ -1454,6 +1491,8 @@ func TestListUnspentDeposits(t *testing.T) { // ChainNotifier and AddressClient are not needed for this test. }, 1) require.NoError(t, err) + _, err = addrMgr.EnsureStaticAddressSeed(ctx) + require.NoError(t, err) // Construct several UTXOs with different confirmation counts. makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo { 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 78a75d042..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 { @@ -151,6 +152,10 @@ type StaticAddressSwap struct { 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 ba3c35eb9..1eed8060c 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) @@ -68,6 +70,7 @@ type Querier interface { 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 diff --git a/loopdb/sqlc/queries/static_address_deposits.sql b/loopdb/sqlc/queries/static_address_deposits.sql index 2987e469e..e9b912fef 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 @@ -43,17 +45,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 +81,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 +105,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 b4fca5d45..ce40bc577 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,18 @@ 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; @@ -64,13 +69,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; @@ -78,13 +94,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 @@ -147,10 +174,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 @@ -161,5 +197,3 @@ FROM ) WHERE d.swap_hash = $1; - - 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..ed23f4209 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 diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 319340168..bc17bec74 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,14 +180,25 @@ 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, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, - 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 ` @@ -191,6 +229,10 @@ type GetStaticAddressLoopInSwapRow struct { 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 @@ -198,6 +240,14 @@ type GetStaticAddressLoopInSwapRow struct { 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) { @@ -229,6 +279,10 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -236,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 } @@ -243,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, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, - 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 @@ -292,6 +365,10 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { 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 @@ -299,6 +376,14 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { 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) { @@ -336,6 +421,10 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -343,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 } @@ -369,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, @@ -381,7 +479,8 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ) ` @@ -397,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 { @@ -412,6 +512,7 @@ func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStati arg.HtlcTimeoutSweepTxID, arg.HtlcTimeoutSweepAddress, arg.Fast, + arg.ChangeStaticAddressID, ) return err } @@ -538,18 +639,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 3305bfcde..f97a2938a 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -4838,9 +4838,14 @@ 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() { @@ -4880,14 +4885,23 @@ 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() { @@ -4934,6 +4948,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. @@ -5685,7 +5706,9 @@ 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 } @@ -5769,6 +5792,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. @@ -6952,13 +6982,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" + @@ -7002,7 +7034,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" + @@ -7010,7 +7042,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" + @@ -7311,7 +7344,9 @@ var file_client_proto_goTypes = []any{ nil, // 89: looprpc.LiquidityParameters.EasyAssetParamsEntry (*lnrpc.OpenChannelRequest)(nil), // 90: lnrpc.OpenChannelRequest (*swapserverrpc.RouteHint)(nil), // 91: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 92: lnrpc.OutPoint + (*lnrpc.SendCoinsRequest)(nil), // 92: lnrpc.SendCoinsRequest + (*lnrpc.SendCoinsResponse)(nil), // 93: lnrpc.SendCoinsResponse + (*lnrpc.OutPoint)(nil), // 94: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ 90, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest @@ -7350,92 +7385,94 @@ var file_client_proto_depIdxs = []int32{ 51, // 33: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified 57, // 34: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation 64, // 35: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 69, // 36: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 92, // 37: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 7, // 38: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 80, // 39: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 81, // 40: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 82, // 41: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 7, // 42: looprpc.Deposit.state:type_name -> looprpc.DepositState - 80, // 43: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 8, // 44: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 80, // 45: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 91, // 46: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 80, // 47: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 87, // 48: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 87, // 49: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 46, // 50: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 14, // 51: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 15, // 52: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 17, // 53: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 19, // 54: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 22, // 55: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 27, // 56: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 53, // 57: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 28, // 58: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 31, // 59: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 28, // 60: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 31, // 61: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 34, // 62: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 36, // 63: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 36, // 64: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 38, // 65: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 42, // 66: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 12, // 67: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 44, // 68: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 48, // 69: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 50, // 70: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 55, // 71: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 58, // 72: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 60, // 73: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 62, // 74: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 65, // 75: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 67, // 76: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 70, // 77: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 72, // 78: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 74, // 79: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 76, // 80: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 78, // 81: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 83, // 82: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 10, // 83: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 16, // 84: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 16, // 85: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 18, // 86: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 21, // 87: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 23, // 88: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 18, // 89: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 54, // 90: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 30, // 91: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 33, // 92: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 29, // 93: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 32, // 94: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 35, // 95: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 37, // 96: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 37, // 97: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 39, // 98: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 43, // 99: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 13, // 100: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 45, // 101: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 49, // 102: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 52, // 103: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 56, // 104: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 59, // 105: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 61, // 106: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 63, // 107: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 66, // 108: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 68, // 109: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 71, // 110: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 73, // 111: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 75, // 112: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 77, // 113: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 79, // 114: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 84, // 115: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 11, // 116: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 84, // [84:117] is the sub-list for method output_type - 51, // [51:84] 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 + 92, // 36: looprpc.NewStaticAddressRequest.send_coins_request:type_name -> lnrpc.SendCoinsRequest + 93, // 37: looprpc.NewStaticAddressResponse.send_coins_response:type_name -> lnrpc.SendCoinsResponse + 69, // 38: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 94, // 39: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 40: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 41: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 42: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 43: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 44: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 45: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 46: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 47: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 91, // 48: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 49: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 50: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 51: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 52: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 53: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 54: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 55: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 56: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 57: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 58: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 59: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 60: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 61: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 62: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 63: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 64: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 65: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 66: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 67: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 68: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 69: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 70: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 71: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 72: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 73: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 74: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 75: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 76: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 77: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 78: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 79: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 80: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 81: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 82: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 83: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 84: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 85: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 86: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 87: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 88: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 89: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 90: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 91: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 92: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 93: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 94: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 95: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 96: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 97: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 98: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 99: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 100: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 101: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 102: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 103: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 104: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 105: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 106: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 107: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 108: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 109: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 110: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 111: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 112: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 113: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 114: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 115: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 116: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 117: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 118: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 86, // [86:119] is the sub-list for method output_type + 53, // [53:86] is the sub-list for method input_type + 53, // [53:53] is the sub-list for extension type_name + 53, // [53:53] is the sub-list for extension extendee + 0, // [0:53] is the sub-list for field type_name } func init() { file_client_proto_init() } diff --git a/looprpc/client.proto b/looprpc/client.proto index 3ae690dc3..f685836ca 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1767,6 +1767,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 { @@ -1779,6 +1787,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 { @@ -2077,6 +2090,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 a50814e4e..069e1cd93 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1204,6 +1204,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": [ @@ -1436,6 +1446,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" }, @@ -1620,6 +1697,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." } } }, @@ -2515,6 +2596,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." } } }, @@ -2529,6 +2614,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." } } }, diff --git a/looprpc/perms.go b/looprpc/perms.go index d646f6671..0a1b5c991 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", diff --git a/staticaddr/address/interface.go b/staticaddr/address/interface.go index 63b6cf7c1..8a9805a6b 100644 --- a/staticaddr/address/interface.go +++ b/staticaddr/address/interface.go @@ -6,15 +6,26 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" ) +// Parameters aliases the script-level static address parameters for callers +// that interact with the address manager API. +type Parameters = script.Parameters + // Store is the database interface that is used to store and retrieve // static addresses. type Store interface { // CreateStaticAddress inserts a new static address with its parameters // into the store. - CreateStaticAddress(ctx context.Context, - addrParams *script.Parameters) error + 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) ([]*script.Parameters, - error) + 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) } diff --git a/staticaddr/address/manager.go b/staticaddr/address/manager.go index 322eed739..b9c5dc3b0 100644 --- a/staticaddr/address/manager.go +++ b/staticaddr/address/manager.go @@ -1,9 +1,11 @@ package address import ( - "bytes" "context" + "database/sql" + "errors" "fmt" + "strings" "sync" "sync/atomic" @@ -29,6 +31,12 @@ const ( maxStaticAddressCSVExpiry = uint32(200 * 144) ) +var ( + // ErrNoStaticAddress is returned when no static address parameters are + // present in the store. + ErrNoStaticAddress = errors.New("no static address parameters found") +) + // ManagerConfig holds the configuration for the address manager. type ManagerConfig struct { // AddressClient is the client that communicates with the loop server @@ -62,6 +70,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. + activeStaticAddresses map[string]*Parameters } // NewManager creates a new address manager. @@ -72,7 +86,8 @@ func NewManager(cfg *ManagerConfig, currentHeight int32) (*Manager, error) { } m := &Manager{ - cfg: cfg, + cfg: cfg, + activeStaticAddresses: make(map[string]*Parameters), } m.currentHeight.Store(currentHeight) @@ -88,6 +103,11 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + err = m.loadActiveAddresses(ctx) + if err != nil { + return err + } + // Communicate to the caller that the address manager has completed its // initialization. close(initChan) @@ -107,54 +127,125 @@ 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. +// loadActiveAddresses rebuilds the runtime address map from the durable DB +// state and re-imports all scripts into lnd. Importing is intentionally +// idempotent so restart paths repair missing wallet watches before deposit +// discovery starts. +func (m *Manager) loadActiveAddresses(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 := m.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, int64(params.Expiry), + ) + 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 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 +254,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 := &script.Parameters{ + 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 retry 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 +406,60 @@ func validateServerAddressParams( return nil } +func (m *Manager) importAddressTapscript(ctx context.Context, + staticAddress *script.StaticAddress) 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 { + // Importing into an lnd instance that already knows the script is + // expected on restart. Treat the duplicate import as success. + if strings.Contains(err.Error(), "already exists") { + log.Infof("Static address tapscript already imported") + return nil + } + + return err + } + + log.Infof("Imported static address taproot script to lnd wallet: %v", + addr) + + return nil +} + +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 +480,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 +498,36 @@ 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 legacy/root static-address +// parameters. func (m *Manager) GetStaticAddressParameters(ctx context.Context) ( *script.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 +540,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 b7bbf79ae..dc59f7518 100644 --- a/staticaddr/address/manager_test.go +++ b/staticaddr/address/manager_test.go @@ -132,6 +132,20 @@ 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, + ) } // TestNewAddressValidatesServerResponse tests that the untrusted @@ -233,12 +247,12 @@ func TestNewAddressAcceptsMaxCSVExpiry(t *testing.T) { 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 43257b81d..35298867f 100644 --- a/staticaddr/address/sql_store.go +++ b/staticaddr/address/sql_store.go @@ -6,7 +6,6 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/keychain" ) @@ -26,7 +25,7 @@ func NewSqlStore(db *loopdb.BaseDB) *SqlStore { // CreateStaticAddress creates a static address record in the database. func (s *SqlStore) CreateStaticAddress(ctx context.Context, - addrParams *script.Parameters) error { + addrParams *Parameters) error { createArgs := sqlc.CreateStaticAddressParams{ ClientPubkey: addrParams.ClientPubkey.SerializeCompressed(), @@ -42,16 +41,23 @@ 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) ( - []*script.Parameters, error) { + []*Parameters, error) { staticAddresses, err := s.baseDB.Queries.AllStaticAddresses(ctx) if err != nil { return nil, err } - var result []*script.Parameters + var result []*Parameters for _, address := range staticAddresses { res, err := s.toAddressParameters(address) if err != nil { @@ -64,10 +70,22 @@ func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( 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) ( - *script.Parameters, error) { + *Parameters, error) { clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) if err != nil { @@ -79,7 +97,8 @@ func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( return nil, err } - return &script.Parameters{ + 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 77560eb24..03a65e00b 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.GetConfirmationHeight()), ) if err != nil { diff --git a/staticaddr/deposit/actions_test.go b/staticaddr/deposit/actions_test.go index 8c0211211..15a913999 100644 --- a/staticaddr/deposit/actions_test.go +++ b/staticaddr/deposit/actions_test.go @@ -8,6 +8,8 @@ import ( "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" ) @@ -52,6 +54,50 @@ func TestFinalizeDepositActionDoesNotBlock(t *testing.T) { } } +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) { diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index d63cc4b74..043139122 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -9,6 +9,9 @@ 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" ) @@ -70,6 +73,11 @@ 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 } // IsInFinalState returns true if the deposit is final. @@ -152,6 +160,19 @@ func (d *Deposit) GetConfirmationHeightNoLock() int64 { return d.ConfirmationHeight } +// 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/fsm.go b/staticaddr/deposit/fsm.go index c5bb85c30..723aaa6aa 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -181,13 +181,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) } @@ -535,10 +535,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 } @@ -546,10 +546,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 8606c7e60..0a3c6170d 100644 --- a/staticaddr/deposit/interface.go +++ b/staticaddr/deposit/interface.go @@ -5,6 +5,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -39,6 +40,14 @@ type AddressManager interface { GetStaticAddressParameters(ctx context.Context) (*script.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 + // 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 61fc8e769..6fcfea283 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -69,8 +70,8 @@ type Manager struct { // mu guards access to the activeDeposits map. mu sync.Mutex - // reconcileMu serializes deposit reconciliation so new deposits are - // discovered and retained exactly once per outpoint. + // reconcileMu serializes startup recovery and deposit reconciliation so + // new deposits are discovered and retained exactly once per outpoint. reconcileMu sync.Mutex // activeDeposits contains all the active static address outputs. @@ -213,6 +214,9 @@ func (m *Manager) notifyActiveDeposits(ctx context.Context, // 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. @@ -222,6 +226,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 @@ -258,6 +267,66 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { return nil } +// 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 load 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 load legacy "+ + "static address ID for deposit %v: %w", + d.OutPoint, err) + } + } + + legacyParams = params + } + + d.AddressParams = legacyParams + } + + 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 @@ -376,6 +445,17 @@ 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) + } + if addressParams.ID <= 0 { + return nil, fmt.Errorf("missing static address ID for deposit %v", + utxo.OutPoint) + } + deposit := &Deposit{ ID: id, state: Deposited, @@ -383,6 +463,7 @@ func (m *Manager) createNewDeposit(ctx context.Context, Value: utxo.Value, ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, + AddressParams: addressParams, } err = m.cfg.Store.CreateDeposit(ctx, deposit) @@ -793,7 +874,17 @@ func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) { // GetAllDeposits returns all known deposits from the database. 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 } // GetVisibleDeposits returns deposits that should be exposed through normal @@ -808,6 +899,11 @@ func (m *Manager) GetVisibleDeposits(ctx context.Context) ([]*Deposit, error) { return nil, err } + err = m.hydrateLegacyDepositAddressParams(ctx, deposits...) + if err != nil { + return nil, err + } + m.mu.Lock() defer m.mu.Unlock() @@ -884,6 +980,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_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index 15b2f0a69..288295a76 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -13,6 +13,7 @@ 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/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/test" @@ -41,8 +42,15 @@ func TestReconcileDepositsSerialized(t *testing.T) { "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + "GetParameters", mock.Anything, + ).Return(&address.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }) mockStore := new(mockStore) var createCalls atomic.Int32 @@ -137,8 +145,15 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + "GetParameters", mock.Anything, + ).Return(&address.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }) mockStore := new(mockStore) mockStore.On( @@ -471,6 +486,12 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { OutPoint: outpoint, Value: btcutil.Amount(100_000), ConfirmationHeight: 77, + AddressParams: &address.Parameters{ + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + ProtocolVersion: version.ProtocolVersion_V0, + }, } deposit.SetState(Deposited) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 78663f9a7..1c6423570 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -12,6 +12,7 @@ import ( "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/swap" "github.com/lightninglabs/loop/swapserverrpc" @@ -112,6 +113,16 @@ 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) ( *script.Parameters, error) { @@ -121,6 +132,39 @@ func (m *mockAddressManager) GetStaticAddressParameters(ctx context.Context) ( 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) GetStaticAddress(ctx context.Context) ( *script.StaticAddress, error) { @@ -481,6 +525,96 @@ func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) { } } +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 + + err = testContext.manager.recoverDeposits(ctx) + require.NoError(t, err) + require.NotNil(t, storedDeposit.AddressParams) + require.NotZero(t, storedDeposit.AddressParams.ID) +} + +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 + + 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].AddressParams.ID) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { @@ -497,19 +631,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), @@ -520,7 +644,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Index: 0xffffffff, }, } - require.NoError(t, err) + storedDeposits := []*Deposit{ { ID: ID, @@ -532,6 +656,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) @@ -540,17 +684,29 @@ 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 + } + } + var manager *Manager + mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return(&script.Parameters{ - Expiry: defaultExpiry, - }, nil) + ).Return(addrParams, nil) mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, ).Return(func() []*lnwallet.Utxo { - currentUtxo := *utxo + if len(utxos) != 1 { + return utxos + } + + currentUtxo := *utxos[0] currentHeight := manager.currentHeight.Load() if currentHeight < defaultDepositConfirmations { currentUtxo.Confirmations = 0 @@ -595,9 +751,6 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { blockErrChan: blockErrChan, } - staticAddress := generateStaticAddress( - context.Background(), testContext, - ) mockAddressManager.On( "GetStaticAddress", mock.Anything, ).Return(staticAddress, nil) @@ -605,19 +758,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/sql_store.go b/staticaddr/deposit/sql_store.go index a49550e5c..0706fb304 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,17 @@ func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { Amount: int64(deposit.Value), ConfirmationHeight: deposit.GetConfirmationHeight(), TimeoutSweepPkScript: deposit.TimeOutSweepPkScript, + StaticAddressID: sql.NullInt32{}, + } + if deposit.AddressParams != nil { + if deposit.AddressParams.ID <= 0 { + return fmt.Errorf("static address ID must be set") + } + + createArgs.StaticAddressID = sql.NullInt32{ + Int32: deposit.AddressParams.ID, + Valid: true, + } } updateArgs := sqlc.InsertDepositUpdateParams{ @@ -147,7 +163,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 } @@ -193,7 +211,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 } @@ -245,8 +265,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{} @@ -296,7 +413,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{ @@ -309,5 +426,57 @@ func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, ExpirySweepTxid: expirySweepTxid, SwapHash: swapHash, FinalizedWithdrawalTx: finalizedWithdrawalTx, - }, nil + } + + 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..4045b2811 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -1,6 +1,7 @@ package deposit import ( + "context" "database/sql" "testing" @@ -8,10 +9,21 @@ import ( "github.com/jackc/pgx/v5" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" ) +func TestCreateDepositRejectsUnpersistedAddress(t *testing.T) { + store := NewSqlStore(nil) + deposit := &Deposit{ + AddressParams: &script.Parameters{}, + } + + err := store.CreateDeposit(context.Background(), deposit) + require.ErrorContains(t, err, "static address ID must be set") +} + func TestToDeposit(t *testing.T) { depositID, err := GetRandomDepositID() require.NoError(t, err) @@ -24,13 +36,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 +56,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 1c432e6f0..ce4f1457c 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -109,6 +110,29 @@ 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 { @@ -158,16 +182,28 @@ func (f *FSM) InitHtlcAction(ctx context.Context, 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 @@ -596,8 +632,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) @@ -607,8 +642,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) @@ -616,8 +650,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) @@ -1134,9 +1167,14 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfirmed := false 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 if invoiceCanceledForNonPayment { err = transitionDepositsToHtlcTimeout( @@ -1177,6 +1215,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 { @@ -1354,6 +1396,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 diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 20b862515..2984617eb 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -12,12 +12,14 @@ import ( "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/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "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" @@ -757,6 +759,7 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { t.Parallel() mockLnd := test.NewMockLnd() + _, clientPubkey := test.CreateKey(20) _, serverKey := test.CreateKey(21) server := &mockStaticAddressServer{ @@ -771,6 +774,10 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { Index: 0, }, Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientPubkey, + PkScript: []byte{0x51, 0x20, 0x01}, + }, } loopIn := &StaticAddressLoopIn{ @@ -804,6 +811,17 @@ 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()].GetPubkey(), + ) + require.Equal( + t, dep.AddressParams.PkScript, + server.request.DepositToClientPubkeys[dep.String()].GetPkScript(), + ) _, routeHints, _, _, err := swap.DecodeInvoice( mockLnd.ChainParams, server.request.SwapInvoice, @@ -878,6 +896,82 @@ func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints( require.Empty(t, checker.outpoints) } +// 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, + PkScript: []byte{0x51, 0x20, 0x02}, + }, + } + 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.StaticAddress.GetPubkey(), + ) + require.Equal( + t, changeParams.PkScript, + server.request.ChangeOutput.StaticAddress.GetPkScript(), + ) + require.Same(t, changeParams, loopIn.ChangeAddressParams) +} + // mockStaticAddressServer captures static-address loop-in requests in tests. type mockStaticAddressServer struct { swapserverrpc.StaticAddressServerClient @@ -916,6 +1010,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 { @@ -1024,10 +1182,18 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, NotificationManager: notificationMgr, + Store: &recordingLoopInStore{}, } f, err := NewFSM(ctx, loopIn, cfg, false) require.NoError(t, err) + htlc, err := loopIn.getHtlc(mockLnd.ChainParams) + require.NoError(t, err) + htlcTx := wire.NewMsgTx(2) + htlcTx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: htlc.PkScript, + }) resultChan := make(chan fsm.EventType, 1) go func() { @@ -1046,7 +1212,7 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { case <-ctx.Done(): t.Fatalf("htlc conf registration not received: %v", ctx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- &chainntnfs.TxConfirmation{Tx: htlcTx} select { case hash := <-mockLnd.FailInvoiceChannel: @@ -2613,7 +2779,9 @@ func TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails( t.Fatalf("htlc conf registration not received: %v", ctx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- invoiceMonitorHtlcConfirmation( + t, f, mockLnd, + ) select { case transition := <-depositMgr.transitionChan: @@ -2700,7 +2868,9 @@ func TestMonitorInvoiceAndHtlcTxRetriesOnlyPendingTimeoutDeposits(t *testing.T) t.Fatalf("htlc conf registration not received: %v", runCtx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- invoiceMonitorHtlcConfirmation( + t, f, mockLnd, + ) return resultChan } @@ -2857,6 +3027,7 @@ func newInvoiceMonitorTestFSM(t *testing.T, ctx context.Context, InvoicesClient: invoicesClient, LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, + Store: &recordingLoopInStore{}, } f, err := NewFSM(ctx, loopIn, cfg, true) @@ -2865,6 +3036,25 @@ func newInvoiceMonitorTestFSM(t *testing.T, ctx context.Context, return f, depositMgr } +// invoiceMonitorHtlcConfirmation returns a confirmation containing the HTLC +// output expected by the invoice monitor. +func invoiceMonitorHtlcConfirmation(t *testing.T, f *FSM, + mockLnd *test.LndMockServices) *chainntnfs.TxConfirmation { + + t.Helper() + + htlc, err := f.loopIn.getHtlc(mockLnd.ChainParams) + require.NoError(t, err) + + htlcTx := wire.NewMsgTx(2) + htlcTx.AddTxOut(&wire.TxOut{ + Value: int64(f.loopIn.TotalDepositAmount()), + PkScript: htlc.PkScript, + }) + + return &chainntnfs.TxConfirmation{Tx: htlcTx} +} + // failingCancelInvoices records cancellation attempts and returns a configured // error after its release channel is closed. type failingCancelInvoices struct { @@ -3080,10 +3270,15 @@ func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) { 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(), @@ -3130,12 +3325,17 @@ func TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure(t *testing.T) { 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(), @@ -3279,6 +3479,13 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( 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 { deposits []*deposit.Deposit diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index d54355a7b..aa54e7277 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" @@ -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. diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 7fcc3ff9a..bf3467f84 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -18,6 +18,7 @@ import ( "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/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/staticutil" @@ -166,6 +167,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. @@ -182,6 +188,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 @@ -204,19 +220,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) { @@ -289,11 +322,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. @@ -329,9 +361,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, }) } @@ -391,36 +428,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. @@ -431,7 +450,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, @@ -470,6 +488,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 @@ -491,6 +559,22 @@ 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 swap's configured payment diff --git a/staticaddr/loopin/loopin_test.go b/staticaddr/loopin/loopin_test.go index 8b0892e66..574c91d75 100644 --- a/staticaddr/loopin/loopin_test.go +++ b/staticaddr/loopin/loopin_test.go @@ -12,6 +12,7 @@ import ( "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/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" @@ -77,7 +78,8 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { Hash: chainhash.Hash{0xaa}, Index: 0, }, - Value: depositValue, + Value: depositValue, + AddressParams: addrParams, }, } @@ -96,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, @@ -183,6 +185,76 @@ func TestPaymentTimeoutDuration(t *testing.T) { } } +// 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 92c4a9f14..4377aa19e 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -21,7 +21,6 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/staticutil" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" @@ -330,7 +329,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 } @@ -373,8 +372,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, @@ -393,7 +402,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, } musig2Session, err := staticutil.CreateMusig2Session( - ctx, m.cfg.Signer, loopIn.AddressParams, loopIn.Address, + ctx, m.cfg.Signer, d, ) if err != nil { return err @@ -458,7 +467,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 *script.Parameters) error { + sweepTx *wire.MsgTx) error { prevOuts := make([]string, len(sweepTx.TxIn)) for i, in := range sweepTx.TxIn { @@ -483,42 +492,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 @@ -665,19 +699,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", @@ -857,15 +880,21 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) ( // 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 { confirmationHeight := d.GetConfirmationHeight() + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %s", d.OutPoint.String()) + } + if !IsSwappable( - uint32(confirmationHeight), blockHeight, csvExpiry, + uint32(confirmationHeight), blockHeight, + d.AddressParams.Expiry, ) { log.Debugf("Skipping deposit %s as it expires before "+ @@ -892,11 +921,11 @@ func SelectDeposits(targetAmount btcutil.Amount, if deposits[i].Value == deposits[j].Value { iExp := blocksUntilDepositExpiry( uint32(iConfirmationHeight), blockHeight, - csvExpiry, + deposits[i].AddressParams.Expiry, ) jExp := blocksUntilDepositExpiry( uint32(jConfirmationHeight), blockHeight, - csvExpiry, + deposits[j].AddressParams.Expiry, ) return iExp < jExp diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 564429c60..7fe2befa0 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -14,6 +14,7 @@ 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/script" "github.com/lightninglabs/loop/swap" @@ -193,9 +194,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) @@ -378,6 +381,14 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) { require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits) } +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) { @@ -615,9 +626,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 } @@ -672,7 +683,6 @@ func TestCheckChange(t *testing.T) { name string inDeps []*deposit.Deposit // deposits referenced by tx inputs outputs []*wire.TxOut // outputs in sweep tx - addr *script.Parameters expectErr bool expectedErrMsg string } @@ -688,7 +698,6 @@ func TestCheckChange(t *testing.T) { PkScript: serverAddr.PkScript, }, }, - addr: changeAddr, }, { name: "single swap change present", @@ -703,43 +712,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", }, @@ -756,7 +781,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -773,7 +797,6 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -794,7 +817,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, }, } @@ -817,7 +839,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 8b36dbe4a..56d68cf17 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" @@ -287,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[:], @@ -342,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), @@ -349,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{ @@ -552,6 +581,16 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } } + 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( @@ -578,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, @@ -587,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{ @@ -605,6 +654,11 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } depositList = orderDepositsBySnapshot(depositList, depositOutpoints) + changeAddressParams, err := toChangeAddressParameters(swap) + if err != nil { + return nil, err + } + loopIn := &StaticAddressLoopIn{ SwapHash: swapHash, SwapPreimage: swapPreImage, @@ -637,7 +691,13 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, ), 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 = @@ -686,3 +746,41 @@ func orderDepositsBySnapshot(deposits []*deposit.Deposit, return orderedDeposits } + +// 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 ffea0f065..fa66cb510 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -539,6 +539,70 @@ func TestGetLoopInByHashOrdersDepositsBySnapshot(t *testing.T) { require.Equal(t, d1.ID, storedSwap.Deposits[1].ID) } +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)) + + confirmedHtlcTxHash := chainhash.Hash{0x55} + swap.HtlcTxHash = &confirmedHtlcTxHash + swap.HtlcOutputIndex = 2 + swap.HtlcOutputValue = 88_000 + require.NoError(t, swapStore.UpdateLoopIn(ctxb, &swap)) + + 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) { diff --git a/staticaddr/script/parameters.go b/staticaddr/script/parameters.go index 89e2470b6..0fa1f73b4 100644 --- a/staticaddr/script/parameters.go +++ b/staticaddr/script/parameters.go @@ -9,6 +9,10 @@ import ( // 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. diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index a25093339..05d131a0a 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -3,6 +3,7 @@ package staticutil import ( "bytes" "context" + "errors" "fmt" "sort" @@ -11,8 +12,8 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "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" @@ -21,8 +22,12 @@ 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) { outpoints := make([]wire.OutPoint, len(deposits)) for i, d := range deposits { @@ -35,9 +40,13 @@ func ToPrevOuts(deposits []*deposit.Deposit, prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(deposits)) for i, d := range deposits { outpoint := outpoints[i] + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for deposit %v", d.OutPoint) + } txOut := &wire.TxOut{ Value: int64(d.Value), - PkScript: pkScript, + PkScript: d.AddressParams.PkScript, } prevOuts[outpoint] = txOut } @@ -45,11 +54,82 @@ func ToPrevOuts(deposits []*deposit.Deposit, return prevOuts, nil } +// DepositClientPubkeys maps each deposit outpoint to the static address +// descriptor 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]*swapserverrpc.StaticAddressDescriptor, error) { + + clientPubkeys := make( + map[string]*swapserverrpc.StaticAddressDescriptor, 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) + } + if len(d.AddressParams.PkScript) == 0 { + return nil, fmt.Errorf("missing static address pkscript "+ + "for deposit %v", d.OutPoint) + } + + depositKey := d.String() + if _, ok := clientPubkeys[depositKey]; ok { + return nil, fmt.Errorf("duplicate outpoint %v", + depositKey) + } + + clientPubkeys[depositKey] = + &swapserverrpc.StaticAddressDescriptor{ + Pubkey: d.AddressParams.ClientPubkey. + SerializeCompressed(), + PkScript: d.AddressParams.PkScript, + } + } + + 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{ + StaticAddress: &swapserverrpc.StaticAddressDescriptor{ + Pubkey: 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 *script.Parameters, - staticAddress *script.StaticAddress) ([]*input.MuSig2SessionInfo, + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( + []*input.MuSig2SessionInfo, [][]byte, error) { musig2Sessions := make([]*input.MuSig2SessionInfo, len(deposits)) @@ -58,7 +138,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 +152,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 *script.Parameters, - staticAddress *script.StaticAddress) ( + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( map[string]*input.MuSig2SessionInfo, map[string][]byte, map[string]int, error) { @@ -86,25 +167,73 @@ 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 { + err := fmt.Errorf("duplicate outpoint %v", depositKey) + return nil, nil, nil, errors.Join( + err, CleanupMusig2Sessions(ctx, signer, sessions), + ) + } + session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, + ctx, signer, deposit, ) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, errors.Join( + err, CleanupMusig2Sessions(ctx, signer, sessions), + ) } - 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. +// CleanupMusig2Sessions releases all supplied MuSig2 sessions. +func CleanupMusig2Sessions(ctx context.Context, + signer lndclient.SignerClient, + sessions map[string]*input.MuSig2SessionInfo) error { + + var cleanupErr error + for depositKey, session := range sessions { + if session == nil { + continue + } + + err := signer.MuSig2Cleanup( + context.WithoutCancel(ctx), session.SessionID, + ) + if err != nil { + cleanupErr = errors.Join( + cleanupErr, fmt.Errorf("unable to clean up MuSig2 "+ + "session for deposit %v: %w", depositKey, err), + ) + } + } + + return cleanupErr +} + +// CreateMusig2Session creates a musig2 session for the deposit's static +// address. func CreateMusig2Session(ctx context.Context, - signer lndclient.SignerClient, addrParams *script.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 ae68b4895..d8d428116 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -3,14 +3,16 @@ package staticutil import ( "bytes" "context" + "errors" "testing" "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/lndclient" + "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" @@ -21,6 +23,37 @@ import ( "github.com/stretchr/testify/require" ) +type sessionCleanupSigner struct { + lndclient.SignerClient + + createCalls int + failCreateAt int + cleaned [][32]byte + cleanupCtxErr []error +} + +func (s *sessionCleanupSigner) MuSig2CreateSession(context.Context, + input.MuSig2Version, *keychain.KeyLocator, [][]byte, + ...lndclient.MuSig2SessionOpts) (*input.MuSig2SessionInfo, error) { + + s.createCalls++ + if s.createCalls == s.failCreateAt { + return nil, errors.New("session creation failed") + } + + sessionID := [32]byte{byte(s.createCalls)} + return &input.MuSig2SessionInfo{SessionID: sessionID}, nil +} + +func (s *sessionCleanupSigner) MuSig2Cleanup(ctx context.Context, + sessionID [32]byte) error { + + s.cleaned = append(s.cleaned, sessionID) + s.cleanupCtxErr = append(s.cleanupCtxErr, ctx.Err()) + + return nil +} + // mustHash converts a hex string to a chainhash.Hash and panics on error. func mustHash(t *testing.T, s string) chainhash.Hash { t.Helper() @@ -36,7 +69,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{ @@ -44,12 +78,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. @@ -59,13 +92,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 { @@ -80,13 +113,169 @@ 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(), + PkScript: []byte{0x51, 0x20, 0x01}, + }, + } + d2 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "5555555555555555555555555555555555555555555555555555555555555555"), + Index: 1, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey2.PubKey(), + PkScript: []byte{0x51, 0x20, 0x02}, + }, + } + + proofs, err := DepositClientPubkeys([]*deposit.Deposit{d1, d2}) + require.NoError(t, err) + require.Equal( + t, clientKey1.PubKey().SerializeCompressed(), + proofs[d1.String()].GetPubkey(), + ) + require.Equal( + t, d1.AddressParams.PkScript, + proofs[d1.String()].GetPkScript(), + ) + require.Equal( + t, clientKey2.PubKey().SerializeCompressed(), + proofs[d2.String()].GetPubkey(), + ) + require.Equal( + t, d2.AddressParams.PkScript, + proofs[d2.String()].GetPkScript(), + ) +} + +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(), + PkScript: []byte{0x51, 0x20, 0x03}, + }, + } + _, err = DepositClientPubkeys([]*deposit.Deposit{d, d}) + require.ErrorContains(t, err, "duplicate outpoint") + }) + + t.Run("missing pkscript", func(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, + } + _, err = DepositClientPubkeys([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address pkscript") + }) +} + +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.StaticAddress.GetPubkey(), + ) + require.Equal(t, params.PkScript, changeOutput.StaticAddress.GetPkScript()) + 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 { @@ -174,7 +363,7 @@ func TestCreateMusig2Session_Success(t *testing.T) { serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) - params := &script.Parameters{ + params := &address.Parameters{ ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Expiry: 10, @@ -182,13 +371,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) } @@ -203,7 +387,7 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) - params := &script.Parameters{ + params := &address.Parameters{ ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Expiry: 12, @@ -211,20 +395,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)) @@ -237,6 +416,43 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { } } +func TestCreateMusig2SessionsPerDepositCleansUpPartialFailure( + t *testing.T) { + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 12, + KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, + } + deposits := []*deposit.Deposit{ + { + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: params, + }, + { + OutPoint: wire.OutPoint{Index: 2}, + AddressParams: params, + }, + } + + signer := &sessionCleanupSigner{failCreateAt: 2} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, _, _, err = CreateMusig2SessionsPerDeposit( + ctx, signer, deposits, + ) + require.ErrorContains(t, err, "session creation failed") + require.Equal(t, [][32]byte{{1}}, signer.cleaned) + require.Equal(t, []error{nil}, signer.cleanupCtxErr) +} + // makeDeposit creates a deposit with the given value for testing. func makeDeposit(value btcutil.Amount) *deposit.Deposit { return &deposit.Deposit{Value: value} diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index 0f32697a9..b2698fc44 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -5,6 +5,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" ) @@ -18,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 3a7927a45..b10582dd3 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" @@ -280,8 +280,7 @@ func (m *Manager) recoverWithdrawals(ctx context.Context) error { } err = m.handleWithdrawal( - ctx, deposits, tx.TxHash(), - tx.TxOut[0].PkScript, + ctx, deposits, tx.TxHash(), tx.TxOut[0].PkScript, ) if err != nil { return err @@ -536,40 +535,59 @@ 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 } + defer func() { + err := staticutil.CleanupMusig2Sessions( + ctx, m.cfg.Signer, sessions, + ) + if err != nil { + log.Warnf("Unable to clean up withdrawal MuSig2 "+ + "sessions: %v", 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 + 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) + } } 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 @@ -577,15 +595,16 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, // Request the server to sign the withdrawal transaction. // - // The withdrawal and change amount are sent to the server with the - // expectation that the server just signs the transaction, without - // performing fee calculations and dust considerations. The client is - // responsible for that. + // All withdrawal outputs, including any change output, are encoded in + // the PSBT. The server signs the transaction as constructed without + // performing fee calculations or dust handling. The client is + // responsible for both. // nolint:lll sigResp, err := m.cfg.StaticAddressServerClient.ServerPsbtWithdrawDeposits( ctx, &staticaddressrpc.ServerPsbtWithdrawRequest{ - WithdrawalPsbt: unsignedPsbt, - DepositToNonces: clientNonces, + WithdrawalPsbt: unsignedPsbt, + DepositToNonces: clientNonces, + DepositToClientPubkeys: depositClientPubkeys, }, ) if err != nil { @@ -664,22 +683,56 @@ 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 +} + +// validateConfirmedWithdrawalInputs verifies that a confirmed withdrawal +// transaction spends every deposit associated with the withdrawal. Withdrawal +// monitoring intentionally watches only the first deposit so an RBF replacement +// can be discovered without registering a new confirmation notification for +// every replacement transaction. However, the spend notification also fires if +// an unrelated transaction spends only that first deposit. Requiring the full +// deposit set prevents such a partial spend from incorrectly transitioning all +// deposits to Withdrawn while still permitting a replacement transaction with a +// different transaction ID or additional inputs. +func validateConfirmedWithdrawalInputs(tx *wire.MsgTx, + deposits []*deposit.Deposit) error { + + inputs := make(map[wire.OutPoint]struct{}, len(tx.TxIn)) + for _, txIn := range tx.TxIn { + inputs[txIn.PreviousOutPoint] = struct{}{} + } + + for _, d := range deposits { + if _, ok := inputs[d.OutPoint]; !ok { + return fmt.Errorf("confirmed transaction %v does not spend "+ + "withdrawal deposit %v", tx.TxHash(), d.OutPoint) + } + } + + return nil +} + // 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, + withdrawalPkScript []byte) 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.GetConfirmationHeight()), ) if err != nil { @@ -690,13 +743,20 @@ 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, withdrawalPkScript, + MinConfs, int32(m.initiationHeight.Load()), ) if err != nil { @@ -710,6 +770,32 @@ 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 + } + + // Since the spend notification above only watches the + // first deposit, verify that the confirmed spender is the + // withdrawal (or one of its RBF replacements) before + // transitioning the complete deposit group. + err = validateConfirmedWithdrawalInputs( + confirmedTx, deposits, + ) + if err != nil { + log.Errorf("Ignoring incomplete withdrawal: %v", + err) + + return + } + err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, deposit.Withdrawn, @@ -723,13 +809,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 "+ @@ -886,12 +973,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) @@ -938,30 +1026,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 6c883a7cc..d3b7ab7a4 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -3,24 +3,54 @@ package withdraw import ( "context" "testing" + "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "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/keychain" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) +type withdrawalCleanupSigner struct { + lndclient.SignerClient + + cleaned [][32]byte + cleanupCtxErr []error +} + +func (s *withdrawalCleanupSigner) MuSig2CreateSession(context.Context, + input.MuSig2Version, *keychain.KeyLocator, [][]byte, + ...lndclient.MuSig2SessionOpts) (*input.MuSig2SessionInfo, error) { + + return &input.MuSig2SessionInfo{SessionID: [32]byte{1}}, nil +} + +func (s *withdrawalCleanupSigner) MuSig2Cleanup(ctx context.Context, + sessionID [32]byte) error { + + s.cleaned = append(s.cleaned, sessionID) + s.cleanupCtxErr = append(s.cleanupCtxErr, ctx.Err()) + + return nil +} + // TestNewManagerHeightValidation ensures the constructor rejects zero heights. func TestNewManagerHeightValidation(t *testing.T) { t.Parallel() @@ -35,6 +65,202 @@ 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)) +} + +// TestValidateConfirmedWithdrawalInputs verifies that a replacement +// transaction must preserve the complete withdrawal deposit set. Additional +// inputs are allowed because they do not change which deposits are withdrawn. +func TestValidateConfirmedWithdrawalInputs(t *testing.T) { + t.Parallel() + + first := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 1}, + } + second := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 2}, + } + deposits := []*deposit.Deposit{first, second} + + partialSpend := wire.NewMsgTx(2) + partialSpend.AddTxIn(&wire.TxIn{ + PreviousOutPoint: first.OutPoint, + }) + err := validateConfirmedWithdrawalInputs(partialSpend, deposits) + require.ErrorContains(t, err, second.OutPoint.String()) + + replacement := wire.NewMsgTx(2) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: first.OutPoint, + }) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: second.OutPoint, + }) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, Index: 3, + }, + }) + require.NoError( + t, validateConfirmedWithdrawalInputs(replacement, deposits), + ) +} + +func TestCreateFinalizedWithdrawalTxCleansUpSessionsOnError(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + signer := &withdrawalCleanupSigner{} + manager := &Manager{cfg: &ManagerConfig{Signer: signer}} + deposits := []*deposit.Deposit{ + { + OutPoint: wire.OutPoint{Index: 1}, + Value: 100_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 144, + PkScript: []byte{0x51}, + KeyLocator: keychain.KeyLocator{ + Family: 1, + Index: 2, + }, + }, + }, + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, _, err = manager.CreateFinalizedWithdrawalTx( + ctx, deposits, nil, 1_000, 0, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + ) + require.ErrorContains( + t, err, "either address or commitment type must be specified", + ) + require.Equal(t, [][32]byte{{1}}, signer.cleaned) + require.Equal(t, []error{nil}, signer.cleanupCtxErr) +} + +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) + withdrawalPkScript := []byte{0x51} + + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{dep}, originalTxHash, + withdrawalPkScript, + ) + 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.Equal(t, withdrawalPkScript, 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..d45a38940 --- /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 HTLC, receive and change key 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) +}