diff --git a/cmd/loop/main.go b/cmd/loop/main.go index 294095ca..127634d2 100644 --- a/cmd/loop/main.go +++ b/cmd/loop/main.go @@ -560,10 +560,7 @@ func parseAmt(text string) (btcutil.Amount, error) { func logSwap(swap *looprpc.SwapStatus) { // If our swap failed, we add our failure reason to the state. - swapState := fmt.Sprintf("%v", swap.State) - if swap.State == looprpc.SwapState_FAILED { - swapState = fmt.Sprintf("%v (%v)", swapState, swap.FailureReason) - } + swapState := monitorSwapState(swap) if swap.Type == looprpc.SwapType_LOOP_OUT { fmt.Printf("%v %v %v %v - %v", @@ -585,10 +582,32 @@ func logSwap(swap *looprpc.SwapStatus) { } } - if swap.State != looprpc.SwapState_INITIATED && - swap.State != looprpc.SwapState_HTLC_PUBLISHED && - swap.State != looprpc.SwapState_PREIMAGE_REVEALED { + showCost := shouldShowSwapCost(swap.GetState()) + if swap.Type == looprpc.SwapType_STATIC_LOOP_IN { + staticState := swap.GetStaticLoopInState() + // Static loop-ins key cost visibility off the dedicated FSM state, not + // the generic SwapState lifecycle used by traditional swaps. + switch staticState { + case looprpc.StaticAddressLoopInSwapState_INIT_HTLC, + looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX, + looprpc.StaticAddressLoopInSwapState_MONITOR_INVOICE_HTLC_TX, + looprpc.StaticAddressLoopInSwapState_SWEEP_STATIC_ADDRESS_HTLC_TIMEOUT, + looprpc.StaticAddressLoopInSwapState_MONITOR_HTLC_TIMEOUT_SWEEP, + looprpc.StaticAddressLoopInSwapState_UNLOCK_DEPOSITS: + showCost = false + + case looprpc.StaticAddressLoopInSwapState_PAYMENT_RECEIVED, + looprpc.StaticAddressLoopInSwapState_HTLC_STATIC_ADDRESS_TIMEOUT_SWEPT, + looprpc.StaticAddressLoopInSwapState_SUCCEEDED, + looprpc.StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED, + looprpc.StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP: + + showCost = true + } + } + + if showCost { fmt.Printf(" (cost: server %v, onchain %v, offchain %v)", swap.CostServer, swap.CostOnchain, swap.CostOffchain, ) @@ -597,6 +616,38 @@ func logSwap(swap *looprpc.SwapStatus) { fmt.Println() } +// monitorSwapState returns the static loop-in FSM label for static swaps and +// the shared swap-state label for all others. +func monitorSwapState(swap *looprpc.SwapStatus) string { + if swap.Type == looprpc.SwapType_STATIC_LOOP_IN { + return swap.GetStaticLoopInState().String() + } + + return genericMonitorSwapState(swap) +} + +// shouldShowSwapCost reports whether a swap's generic state is terminal enough +// to include the persisted cost summary in monitor output. +func shouldShowSwapCost(loopState looprpc.SwapState) bool { + return loopState != looprpc.SwapState_INITIATED && + loopState != looprpc.SwapState_HTLC_PUBLISHED && + loopState != looprpc.SwapState_PREIMAGE_REVEALED +} + +// genericMonitorSwapState formats the shared swap-state label and failure +// reason used by non-static swaps. +func genericMonitorSwapState(swap *looprpc.SwapStatus) string { + loopState := swap.GetState() + swapState := fmt.Sprintf("%v", loopState) + if loopState == looprpc.SwapState_FAILED { + swapState = fmt.Sprintf( + "%v (%v)", swapState, swap.FailureReason, + ) + } + + return swapState +} + // getClientConn dials the loopd gRPC server with TLS and macaroon auth. func getClientConn(address, tlsCertPath, macaroonPath string) (daemonConn, func(), error) { diff --git a/cmd/loop/monitor_test.go b/cmd/loop/monitor_test.go new file mode 100644 index 00000000..9ed4841e --- /dev/null +++ b/cmd/loop/monitor_test.go @@ -0,0 +1,275 @@ +package main + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/lightninglabs/loop/looprpc" + "github.com/stretchr/testify/require" +) + +// TestMonitorSwapStateKeepsRegularLoopInFailureReason preserves the generic +// failure suffix for regular loop-in swaps so terminal errors stay visible. +func TestMonitorSwapStateKeepsRegularLoopInFailureReason(t *testing.T) { + swap := &looprpc.SwapStatus{ + Type: looprpc.SwapType_LOOP_IN, + State: looprpc.SwapState_FAILED, + FailureReason: looprpc. + FailureReason_FAILURE_REASON_TIMEOUT, + } + + got := monitorSwapState(swap) + require.Equal(t, "FAILED (FAILURE_REASON_TIMEOUT)", got) +} + +// TestMonitorSwapStateLabelsStaticLoopInStages locks the precise static loop-in +// stage names so monitor output stays stable as the FSM evolves. +func TestMonitorSwapStateLabelsStaticLoopInStages(t *testing.T) { + tests := []struct { + name string + staticState looprpc.StaticAddressLoopInSwapState + want string + }{ + { + name: "init htlc", + staticState: looprpc.StaticAddressLoopInSwapState_INIT_HTLC, + want: "INIT_HTLC", + }, + { + name: "sign htlc", + staticState: looprpc. + StaticAddressLoopInSwapState_SIGN_HTLC_TX, + want: "SIGN_HTLC_TX", + }, + { + name: "monitor invoice and htlc", + staticState: looprpc. + StaticAddressLoopInSwapState_MONITOR_INVOICE_HTLC_TX, + want: "MONITOR_INVOICE_HTLC_TX", + }, + { + name: "unlock deposits", + staticState: looprpc. + StaticAddressLoopInSwapState_UNLOCK_DEPOSITS, + want: "UNLOCK_DEPOSITS", + }, + { + name: "payment received", + staticState: looprpc.StaticAddressLoopInSwapState_PAYMENT_RECEIVED, + want: "PAYMENT_RECEIVED", + }, + { + name: "timeout sweep", + staticState: looprpc. + StaticAddressLoopInSwapState_SWEEP_STATIC_ADDRESS_HTLC_TIMEOUT, + want: "SWEEP_STATIC_ADDRESS_HTLC_TIMEOUT", + }, + { + name: "monitor timeout sweep", + staticState: looprpc. + StaticAddressLoopInSwapState_MONITOR_HTLC_TIMEOUT_SWEEP, + want: "MONITOR_HTLC_TIMEOUT_SWEEP", + }, + { + name: "timeout swept", + staticState: looprpc. + StaticAddressLoopInSwapState_HTLC_STATIC_ADDRESS_TIMEOUT_SWEPT, + want: "HTLC_STATIC_ADDRESS_TIMEOUT_SWEPT", + }, + { + name: "success", + staticState: looprpc.StaticAddressLoopInSwapState_SUCCEEDED, + want: "SUCCEEDED", + }, + { + name: "succeeded transitioning failed", + staticState: looprpc. + StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED, + want: "SUCCEEDED_TRANSITIONING_FAILED", + }, + { + name: "failed", + staticState: looprpc. + StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP, + want: "FAILED_STATIC_ADDRESS_SWAP", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + swap := &looprpc.SwapStatus{ + Type: looprpc.SwapType_STATIC_LOOP_IN, + StaticLoopInStateOptional: &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: test.staticState, + }, + } + + got := monitorSwapState(swap) + require.Equal(t, test.want, got) + }) + } + + initSwap := &looprpc.SwapStatus{ + Type: looprpc.SwapType_STATIC_LOOP_IN, + StaticLoopInStateOptional: &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: looprpc. + StaticAddressLoopInSwapState_INIT_HTLC, + }, + } + signSwap := &looprpc.SwapStatus{ + Type: looprpc.SwapType_STATIC_LOOP_IN, + StaticLoopInStateOptional: &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: looprpc. + StaticAddressLoopInSwapState_SIGN_HTLC_TX, + }, + } + require.NotEqual(t, monitorSwapState(initSwap), monitorSwapState(signSwap)) +} + +// TestMonitorSwapStateLabelsUnknownStaticLoopIn ensures the absent static +// oneof maps to the UNKNOWN static loop-in label. +func TestMonitorSwapStateLabelsUnknownStaticLoopIn(t *testing.T) { + swap := &looprpc.SwapStatus{ + Type: looprpc.SwapType_STATIC_LOOP_IN, + } + + got := monitorSwapState(swap) + require.Equal(t, "UNKNOWN_STATIC_ADDRESS_SWAP_STATE", got) +} + +// TestLogSwapHidesStaticLoopInCostForInFlightState proves in-flight static FSM +// state suppresses cost even with generic SUCCESS set deliberately. +func TestLogSwapHidesStaticLoopInCostForInFlightState(t *testing.T) { + swap := &looprpc.SwapStatus{ + LastUpdateTime: time.Unix(1, 0).UnixNano(), + Type: looprpc.SwapType_STATIC_LOOP_IN, + State: looprpc.SwapState_SUCCESS, + StaticLoopInStateOptional: &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: looprpc. + StaticAddressLoopInSwapState_INIT_HTLC, + }, + Amt: 50_000, + CostServer: 11, + CostOnchain: 22, + CostOffchain: 33, + HtlcAddressP2Wsh: "bc1qstaticinflighttestaddress", + } + + output := captureStdout(t, func() { + logSwap(swap) + }) + + require.Contains(t, output, "STATIC_LOOP_IN INIT_HTLC 0.00050000 BTC") + require.NotContains(t, output, "(cost:") +} + +// TestLogSwapHidesCostForUnknownStaticLoopInState ensures unknown static +// states print the UNKNOWN label without a cost summary. +func TestLogSwapHidesCostForUnknownStaticLoopInState(t *testing.T) { + swap := &looprpc.SwapStatus{ + LastUpdateTime: time.Unix(3, 0).UnixNano(), + Type: looprpc.SwapType_STATIC_LOOP_IN, + StaticLoopInStateOptional: &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: looprpc. + StaticAddressLoopInSwapState_UNKNOWN_STATIC_ADDRESS_SWAP_STATE, + }, + Amt: 50_000, + CostServer: 11, + CostOnchain: 22, + CostOffchain: 33, + HtlcAddressP2Wsh: "bc1qunknownstaticterminaltestaddress", + } + + output := captureStdout(t, func() { + logSwap(swap) + }) + + require.Contains( + t, output, "STATIC_LOOP_IN UNKNOWN_STATIC_ADDRESS_SWAP_STATE 0.00050000 BTC", + ) + require.NotContains(t, output, "(cost:") +} + +// TestLogSwapShowsStaticLoopInCostForTerminalState preserves terminal cost +// output, including the P2WSH address, once the static loop-in is done. +func TestLogSwapShowsStaticLoopInCostForTerminalState(t *testing.T) { + swap := &looprpc.SwapStatus{ + LastUpdateTime: time.Unix(2, 0).UnixNano(), + Type: looprpc.SwapType_STATIC_LOOP_IN, + State: looprpc.SwapState_INITIATED, + StaticLoopInStateOptional: &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: looprpc. + StaticAddressLoopInSwapState_SUCCEEDED, + }, + Amt: 50_000, + CostServer: 11, + CostOnchain: 22, + CostOffchain: 33, + HtlcAddressP2Wsh: "bc1qstaticterminaltestaddress", + } + + output := captureStdout(t, func() { + logSwap(swap) + }) + + require.Contains( + t, output, + "STATIC_LOOP_IN SUCCEEDED 0.00050000 BTC - P2WSH: bc1qstaticterminaltestaddress", + ) + require.Contains( + t, output, + "(cost: server 11, onchain 22, offchain 33)", + ) +} + +// TestLogSwapDisplaysStaticLoopInTypeAndStage ensures the monitor output names +// the static loop-in type and active FSM stage instead of collapsing them. +func TestLogSwapDisplaysStaticLoopInTypeAndStage(t *testing.T) { + swap := &looprpc.SwapStatus{ + LastUpdateTime: time.Unix(1, 0).UnixNano(), + Type: looprpc.SwapType_STATIC_LOOP_IN, + StaticLoopInStateOptional: &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: looprpc. + StaticAddressLoopInSwapState_SIGN_HTLC_TX, + }, + Amt: 50_000, + HtlcAddressP2Wsh: "tb1q5cyxnuxmeuwuvkwfem96llyxf8duyshm56t8k8", + } + + output := captureStdout(t, func() { + logSwap(swap) + }) + + require.Contains( + t, output, "STATIC_LOOP_IN SIGN_HTLC_TX 0.00050000 BTC", + ) + require.Contains( + t, output, "P2WSH: tb1q5cyxnuxmeuwuvkwfem96llyxf8duyshm56t8k8", + ) + require.NotContains(t, output, "STATIC_LOOP_IN INIT_HTLC") +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + originalStdout := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + + fn() + + require.NoError(t, writer.Close()) + os.Stdout = originalStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + + return strings.TrimSpace(buf.String()) +} diff --git a/cmd/loop/swaps.go b/cmd/loop/swaps.go index 9323f6ce..419522b6 100644 --- a/cmd/loop/swaps.go +++ b/cmd/loop/swaps.go @@ -15,9 +15,10 @@ import ( var listSwapsCommand = &cli.Command{ Name: "listswaps", - Usage: "list all swaps in the local database", - Description: "Allows the user to get a list of all swaps that are " + - "currently stored in the database", + Usage: "list traditional Loop In and Loop Out swaps", + Description: "Lists traditional Loop In and Loop Out swaps that are " + + "currently stored in the database. Static address loop-ins are not " + + "included; use `loop static listswaps` to view them.", Action: listSwaps, Flags: []cli.Flag{ &cli.BoolFlag{ @@ -128,10 +129,12 @@ func listSwaps(ctx context.Context, cmd *cli.Command) error { var swapInfoCommand = &cli.Command{ Name: "swapinfo", - Usage: "show the status of a swap", + Usage: "show the status of a traditional swap", ArgsUsage: "id", - Description: "Allows the user to get the status of a single swap " + - "currently stored in the database", + Description: "Shows the status of a traditional Loop In or Loop Out " + + "swap currently stored in the database. Static address loop-ins " + + "must be viewed with `loop static listswaps`; there is no generic " + + "per-swap static lookup command.", Flags: []cli.Flag{ &cli.Uint64Flag{ Name: "id", diff --git a/cmd/loop/testdata/sessions/basic-swaps/04_loop-monitor-static-loop-in.json b/cmd/loop/testdata/sessions/basic-swaps/04_loop-monitor-static-loop-in.json new file mode 100644 index 00000000..451d34f3 --- /dev/null +++ b/cmd/loop/testdata/sessions/basic-swaps/04_loop-monitor-static-loop-in.json @@ -0,0 +1,267 @@ +{ + "metadata": { + "args": [ + "loop", + "monitor", + "--network", + "regtest" + ], + "env": {}, + "version": "0.33.3-beta commit= commit_hash=", + "run_error": "recv: rpc error: code = Canceled desc = context canceled", + "duration": 25407490958, + "clock_start_unix": 1784150305 + }, + "events": [ + { + "time_ms": 13, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/Monitor", + "event": "send", + "message_type": "looprpc.MonitorRequest", + "payload": {} + } + }, + { + "time_ms": 13, + "kind": "stdout", + "data": { + "lines": [ + "Note: offchain cost may report as 0 after loopd restart during swap\n" + ] + } + }, + { + "time_ms": 16479, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/Monitor", + "event": "recv", + "message_type": "looprpc.SwapStatus", + "payload": { + "amt": "500000", + "id": "bec3777d45337f652d96136e5714ed90a5d4f7cda87b5842110ecc38c9a65826", + "id_bytes": "vsN3fUUzf2UtlhNuVxTtkKXU982oe1hCEQ7MOMmmWCY=", + "type": "STATIC_LOOP_IN", + "state": "INITIATED", + "static_loop_in_state": "INIT_HTLC", + "failure_reason": "FAILURE_REASON_NONE", + "initiation_time": "1784150320647961000", + "last_update_time": "1784150321494895000", + "htlc_address": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2wsh": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2tr": "", + "cost_server": "0", + "cost_onchain": "0", + "cost_offchain": "0", + "last_hop": "", + "outgoing_chan_set": [], + "label": "", + "asset_info": null + } + } + }, + { + "time_ms": 16479, + "kind": "stdout", + "data": { + "lines": [ + "2026-07-15T18:18:41-03:00 STATIC_LOOP_IN INIT_HTLC 0.00500000 BTC - P2WSH: bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv\n" + ] + } + }, + { + "time_ms": 16482, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/Monitor", + "event": "recv", + "message_type": "looprpc.SwapStatus", + "payload": { + "amt": "500000", + "id": "bec3777d45337f652d96136e5714ed90a5d4f7cda87b5842110ecc38c9a65826", + "id_bytes": "vsN3fUUzf2UtlhNuVxTtkKXU982oe1hCEQ7MOMmmWCY=", + "type": "STATIC_LOOP_IN", + "state": "INITIATED", + "static_loop_in_state": "SIGN_HTLC_TX", + "failure_reason": "FAILURE_REASON_NONE", + "initiation_time": "1784150320647961000", + "last_update_time": "1784150321499639000", + "htlc_address": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2wsh": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2tr": "", + "cost_server": "0", + "cost_onchain": "0", + "cost_offchain": "0", + "last_hop": "", + "outgoing_chan_set": [], + "label": "", + "asset_info": null + } + } + }, + { + "time_ms": 16482, + "kind": "stdout", + "data": { + "lines": [ + "2026-07-15T18:18:41-03:00 STATIC_LOOP_IN SIGN_HTLC_TX 0.00500000 BTC - P2WSH: bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv\n" + ] + } + }, + { + "time_ms": 16530, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/Monitor", + "event": "recv", + "message_type": "looprpc.SwapStatus", + "payload": { + "amt": "500000", + "id": "bec3777d45337f652d96136e5714ed90a5d4f7cda87b5842110ecc38c9a65826", + "id_bytes": "vsN3fUUzf2UtlhNuVxTtkKXU982oe1hCEQ7MOMmmWCY=", + "type": "STATIC_LOOP_IN", + "state": "INITIATED", + "static_loop_in_state": "MONITOR_INVOICE_HTLC_TX", + "failure_reason": "FAILURE_REASON_NONE", + "initiation_time": "1784150320647961000", + "last_update_time": "1784150321548450000", + "htlc_address": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2wsh": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2tr": "", + "cost_server": "0", + "cost_onchain": "0", + "cost_offchain": "0", + "last_hop": "", + "outgoing_chan_set": [], + "label": "", + "asset_info": null + } + } + }, + { + "time_ms": 16530, + "kind": "stdout", + "data": { + "lines": [ + "2026-07-15T18:18:41-03:00 STATIC_LOOP_IN MONITOR_INVOICE_HTLC_TX 0.00500000 BTC - P2WSH: bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv\n" + ] + } + }, + { + "time_ms": 17027, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/Monitor", + "event": "recv", + "message_type": "looprpc.SwapStatus", + "payload": { + "amt": "500000", + "id": "bec3777d45337f652d96136e5714ed90a5d4f7cda87b5842110ecc38c9a65826", + "id_bytes": "vsN3fUUzf2UtlhNuVxTtkKXU982oe1hCEQ7MOMmmWCY=", + "type": "STATIC_LOOP_IN", + "state": "INITIATED", + "static_loop_in_state": "PAYMENT_RECEIVED", + "failure_reason": "FAILURE_REASON_NONE", + "initiation_time": "1784150320647961000", + "last_update_time": "1784150322044521000", + "htlc_address": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2wsh": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2tr": "", + "cost_server": "1828", + "cost_onchain": "0", + "cost_offchain": "0", + "last_hop": "", + "outgoing_chan_set": [], + "label": "", + "asset_info": null + } + } + }, + { + "time_ms": 17027, + "kind": "stdout", + "data": { + "lines": [ + "2026-07-15T18:18:42-03:00 STATIC_LOOP_IN PAYMENT_RECEIVED 0.00500000 BTC - P2WSH: bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv (cost: server 1828, onchain 0, offchain 0)\n" + ] + } + }, + { + "time_ms": 17036, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/Monitor", + "event": "recv", + "message_type": "looprpc.SwapStatus", + "payload": { + "amt": "500000", + "id": "bec3777d45337f652d96136e5714ed90a5d4f7cda87b5842110ecc38c9a65826", + "id_bytes": "vsN3fUUzf2UtlhNuVxTtkKXU982oe1hCEQ7MOMmmWCY=", + "type": "STATIC_LOOP_IN", + "state": "INITIATED", + "static_loop_in_state": "SUCCEEDED", + "failure_reason": "FAILURE_REASON_NONE", + "initiation_time": "1784150320647961000", + "last_update_time": "1784150322052572000", + "htlc_address": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2wsh": "bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv", + "htlc_address_p2tr": "", + "cost_server": "1828", + "cost_onchain": "0", + "cost_offchain": "0", + "last_hop": "", + "outgoing_chan_set": [], + "label": "", + "asset_info": null + } + } + }, + { + "time_ms": 17036, + "kind": "stdout", + "data": { + "lines": [ + "2026-07-15T18:18:42-03:00 STATIC_LOOP_IN SUCCEEDED 0.00500000 BTC - P2WSH: bcrt1qjsl6tmv2vf3p5wwv28jh6xwettds934k02c3l5w6zyjsyrsd3lfskgdjmv (cost: server 1828, onchain 0, offchain 0)\n" + ] + } + }, + { + "time_ms": 25406, + "kind": "signal", + "data": { + "signal": "interrupt" + } + }, + { + "time_ms": 25407, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/Monitor", + "event": "error", + "error": "rpc error: code = Canceled desc = context canceled", + "status": { + "code": 1, + "message": "context canceled" + } + } + }, + { + "time_ms": 25407, + "kind": "stderr", + "data": { + "lines": [ + "[loop] recv: rpc error: code = Canceled desc = context canceled\n" + ] + } + }, + { + "time_ms": 25407, + "kind": "exit", + "data": { + "run_error": "recv: rpc error: code = Canceled desc = context canceled" + } + } + ] +} diff --git a/docs/loop.1 b/docs/loop.1 index b100a83b..1e21905b 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -203,7 +203,7 @@ fetches a new L402 authentication token from the server \fB--help, -h\fP: show help .SH listswaps -list all swaps in the local database +list traditional Loop In and Loop Out swaps .PP \fB--channel\fP="": the comma-separated list of short channel IDs of the channels to loop out @@ -233,7 +233,7 @@ list all swaps in the local database \fB--start_time_ns\fP="": Unix timestamp in nanoseconds to select swaps initiated after this time (default: 0) .SH swapinfo -show the status of a swap +show the status of a traditional swap .PP \fB--help, -h\fP: show help diff --git a/docs/loop.md b/docs/loop.md index 688d3776..316cebdc 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -235,9 +235,9 @@ The following flags are supported: ### `listswaps` command -list all swaps in the local database. +list traditional Loop In and Loop Out swaps. -Allows the user to get a list of all swaps that are currently stored in the database. +Lists traditional Loop In and Loop Out swaps that are currently stored in the database. Static address loop-ins are not included; use `loop static listswaps` to view them. Usage: @@ -261,9 +261,9 @@ The following flags are supported: ### `swapinfo` command -show the status of a swap. +show the status of a traditional swap. -Allows the user to get the status of a single swap currently stored in the database. +Shows the status of a traditional Loop In or Loop Out swap currently stored in the database. Static address loop-ins must be viewed with `loop static listswaps`; there is no generic per-swap static lookup command. Usage: diff --git a/interface.go b/interface.go index 10a86bfa..4b8623bf 100644 --- a/interface.go +++ b/interface.go @@ -4,6 +4,7 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/taproot-assets/rfqmath" @@ -476,8 +477,12 @@ type SwapInfoKit struct { LastUpdateTime time.Time } -// SwapInfo exposes common info fields for loop in and loop out swaps. +// SwapInfo exposes common info fields for traditional swaps and static address +// loop-ins. type SwapInfo struct { + // SwapStateData.State is authoritative for swap.TypeIn and swap.TypeOut. + // For swap.TypeStaticAddressLoopIn, StaticAddressLoopInState is + // authoritative and State remains its zero value, loopdb.StateInitiated. loopdb.SwapStateData loopdb.SwapContract @@ -488,9 +493,14 @@ type SwapInfo struct { // SwapHash stores the swap preimage hash. SwapHash lntypes.Hash - // SwapType describes whether this is a loop in or loop out swap. + // SwapType describes the kind of swap. SwapType swap.Type + // StaticAddressLoopInState stores the precise static address loop-in FSM + // state when SwapType is swap.TypeStaticAddressLoopIn. For traditional + // swaps, it remains the fsm.StateType zero value, fsm.EmptyState. + StaticAddressLoopInState fsm.StateType + // HtlcAddressP2WSH stores the address of the P2WSH (native segwit) // swap htlc. This is used for both loop-in and loop-out. HtlcAddressP2WSH btcutil.Address diff --git a/liquidity/parameters.go b/liquidity/parameters.go index 9e8e5cc0..3b365cc0 100644 --- a/liquidity/parameters.go +++ b/liquidity/parameters.go @@ -400,9 +400,16 @@ func rpcToFee(req *clientrpc.LiquidityParameters) (FeeLimit, error) { // rpcToRule switches on rpc rule type to convert to our rule interface. func rpcToRule(rule *clientrpc.LiquidityRule) (*SwapRule, error) { - swapType := swap.TypeOut - if rule.SwapType == clientrpc.SwapType_LOOP_IN { + var swapType swap.Type + switch rule.SwapType { + case clientrpc.SwapType_LOOP_OUT: + swapType = swap.TypeOut + + case clientrpc.SwapType_LOOP_IN: swapType = swap.TypeIn + + default: + return nil, fmt.Errorf("unknown swap type: %v", rule.SwapType) } switch rule.Type { diff --git a/liquidity/parameters_test.go b/liquidity/parameters_test.go index 0e54dbf1..7af73c0d 100644 --- a/liquidity/parameters_test.go +++ b/liquidity/parameters_test.go @@ -3,9 +3,62 @@ package liquidity import ( "testing" + clientrpc "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/swap" "github.com/stretchr/testify/require" ) +// TestRPCToRuleSwapType verifies RPC swap type conversion. +func TestRPCToRuleSwapType(t *testing.T) { + tests := []struct { + name string + swapType clientrpc.SwapType + wantType swap.Type + wantErr bool + }{ + { + name: "loop out", + swapType: clientrpc.SwapType_LOOP_OUT, + wantType: swap.TypeOut, + }, + { + name: "loop in", + swapType: clientrpc.SwapType_LOOP_IN, + wantType: swap.TypeIn, + }, + { + name: "static loop in rejected", + swapType: clientrpc.SwapType_STATIC_LOOP_IN, + wantErr: true, + }, + { + name: "unknown swap type rejected", + swapType: clientrpc.SwapType(99), + wantErr: true, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + rpcRule := &clientrpc.LiquidityRule{ + Type: clientrpc.LiquidityRuleType_THRESHOLD, + IncomingThreshold: 10, + OutgoingThreshold: 20, + SwapType: testCase.swapType, + } + + got, err := rpcToRule(rpcRule) + if testCase.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, testCase.wantType, got.Type) + }) + } +} + // TestValidateRestrictions tests validating client restrictions against a set // of server restrictions. func TestValidateRestrictions(t *testing.T) { diff --git a/loopd/daemon.go b/loopd/daemon.go index 68b76141..f625aa27 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -110,6 +110,10 @@ type Daemon struct { macaroonService *lndclient.MacaroonService } +// staticLoopInStatusChanBuffer keeps the shared swap-status fanout from +// stalling static loop-in FSM progress during transient subscriber gaps. +const staticLoopInStatusChanBuffer = 20 + // New creates a new instance of the loop client daemon. func New(config *Config, lisCfg *ListenerCfg) *Daemon { return &Daemon{ @@ -681,6 +685,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { LightningClient: d.lnd.Client, } openChannelManager = openchannel.NewManager(openChannelCfg) + statusChan := make(chan loop.SwapInfo, staticLoopInStatusChanBuffer) // Run the deposit swap hash migration. err = loopin.MigrateDepositSwapHash( @@ -701,6 +706,11 @@ func (d *Daemon) initialize(withMacaroonService bool) error { return err } + statusUpdater := &staticLoopInStatusUpdater{ + statusChan: statusChan, + mainCtx: d.mainCtx, + chainParams: d.lnd.ChainParams, + } staticLoopInManager, err = loopin.NewManager(&loopin.Config{ Server: staticAddressClient, @@ -718,6 +728,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { ChainParams: d.lnd.ChainParams, Signer: d.lnd.Signer, ValidateLoopInContract: loop.ValidateLoopInContract, + SendUpdate: statusUpdater.sendUpdate, MaxStaticAddrHtlcFeePercentage: d.cfg.MaxStaticAddrHtlcFeePercentage, MaxStaticAddrHtlcBackupFeePercentage: d.cfg.MaxStaticAddrHtlcBackupFeePercentage, }, blockHeight) @@ -783,7 +794,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { lnd: &d.lnd.LndServices, swaps: make(map[lntypes.Hash]loop.SwapInfo), subscribers: make(map[int]chan<- any), - statusChan: make(chan loop.SwapInfo), + statusChan: statusChan, mainCtx: d.mainCtx, reservationManager: reservationManager, instantOutManager: instantOutManager, diff --git a/loopd/static_loopin_status_updater.go b/loopd/static_loopin_status_updater.go new file mode 100644 index 00000000..aa0592cb --- /dev/null +++ b/loopd/static_loopin_status_updater.go @@ -0,0 +1,47 @@ +package loopd + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/staticaddr/loopin" +) + +// staticLoopInStatusUpdater publishes static-address loop-in status updates to +// the client-facing swap stream. +type staticLoopInStatusUpdater struct { + // statusChan sends updates to the client-facing swap stream. + statusChan chan<- loop.SwapInfo + + // mainCtx is canceled when loopd is shutting down. + mainCtx context.Context + + // chainParams are used to reconstruct the static loop-in HTLC address. + chainParams *chaincfg.Params +} + +// sendUpdate converts the persisted static-address loop-in into swap info and +// forwards it to the status stream unless either context is canceled. +func (u *staticLoopInStatusUpdater) sendUpdate(ctx context.Context, + swp *loopin.StaticAddressLoopIn) error { + + info, err := staticAddressLoopInSwapInfoWithChainParams( + swp, u.chainParams, + ) + if err != nil { + return fmt.Errorf("unable to notify static loop-in update: %w", err) + } + + select { + case u.statusChan <- *info: + return nil + + case <-ctx.Done(): + return ctx.Err() + + case <-u.mainCtx.Done(): + return u.mainCtx.Err() + } +} diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d89dbbce..28b4f722 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -411,6 +411,8 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, } var swapType looprpc.SwapType + staticLoopInState := looprpc. + StaticAddressLoopInSwapState_UNKNOWN_STATIC_ADDRESS_SWAP_STATE var ( htlcAddress string htlcAddressP2TR string @@ -437,6 +439,27 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, lastHop = loopSwap.LastHop[:] } + case swap.TypeStaticAddressLoopIn: + // Static loop-ins surface their precise FSM state through the + // optional oneof and keep the reconstructed HTLC P2WSH address, + // not the reusable static address. + swapType = looprpc.SwapType_STATIC_LOOP_IN + staticLoopInState = toClientStaticAddressLoopInState( + loopSwap.StaticAddressLoopInState, + ) + + if loopSwap.HtlcAddressP2WSH == nil { + return nil, errors.New( + "missing static address loop-in P2WSH HTLC address", + ) + } + htlcAddressP2WSH = loopSwap.HtlcAddressP2WSH.EncodeAddress() + htlcAddress = htlcAddressP2WSH + + if loopSwap.LastHop != nil { + lastHop = loopSwap.LastHop[:] + } + case swap.TypeOut: swapType = looprpc.SwapType_LOOP_OUT if loopSwap.HtlcAddressP2WSH != nil { @@ -478,7 +501,7 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, return nil, errors.New("unknown swap type") } - return &looprpc.SwapStatus{ + rpcSwap := &looprpc.SwapStatus{ Amt: int64(loopSwap.AmountRequested), Id: loopSwap.SwapHash.String(), IdBytes: loopSwap.SwapHash[:], @@ -497,7 +520,15 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, LastHop: lastHop, OutgoingChanSet: outGoingChanSet, AssetInfo: assetInfo, - }, nil + } + if swapType == looprpc.SwapType_STATIC_LOOP_IN { + rpcSwap.StaticLoopInStateOptional = + &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: staticLoopInState, + } + } + + return rpcSwap, nil } // Monitor will return a stream of swap updates for currently active swaps. @@ -518,27 +549,28 @@ func (s *swapClientServer) Monitor(in *looprpc.MonitorRequest, // Start a notification queue for this subscriber. queue := queue.NewConcurrentQueue(20) queue.Start() + ctx := server.Context() - // Add this subscriber to the global subscriber list. Also create a - // snapshot of all pending and completed swaps within the lock, to - // prevent subscribers from receiving duplicate updates. s.swapsLock.Lock() id := s.nextSubscriberID s.nextSubscriberID++ s.subscribers[id] = queue.ChanIn() - - var pendingSwaps, completedSwaps []loop.SwapInfo - for _, swap := range s.swaps { - if swap.State.Type() == loopdb.StateTypePending { - pendingSwaps = append(pendingSwaps, swap) - } else { - completedSwaps = append(completedSwaps, swap) - } - } - + pendingSwaps, completedSwaps := s.monitorCachedSwaps() s.swapsLock.Unlock() + err := s.appendStaticAddressLoopInMonitorSnapshot( + ctx, &pendingSwaps, &completedSwaps, + ) + if err != nil { + s.swapsLock.Lock() + delete(s.subscribers, id) + s.swapsLock.Unlock() + queue.Stop() + + return err + } + defer func() { s.swapsLock.Lock() delete(s.subscribers, id) @@ -568,6 +600,13 @@ func (s *swapClientServer) Monitor(in *looprpc.MonitorRequest, ) }) + // Static-address loop-in updates can arrive from both the initial snapshot + // and the live queue. Build a high-water mark from the snapshot so we can + // suppress stale duplicate snapshot items without dropping newer live ones. + staticSnapshotHighWater := staticAddressLoopInMonitorHighWater( + filteredSwaps, + ) + // Return swaps to caller. for _, swap := range filteredSwaps { if err := send(swap); err != nil { @@ -585,6 +624,13 @@ func (s *swapClientServer) Monitor(in *looprpc.MonitorRequest, } swap := queueItem.(loop.SwapInfo) + if isInitialStaticAddressLoopInStale( + staticSnapshotHighWater, swap, + ) { + + continue + } + if err := send(swap); err != nil { return err } @@ -600,6 +646,102 @@ func (s *swapClientServer) Monitor(in *looprpc.MonitorRequest, } } +// staticAddressLoopInMonitorHighWater records the latest snapshot item for each +// static-address loop-in swap hash. +func staticAddressLoopInMonitorHighWater( + swaps []loop.SwapInfo) map[lntypes.Hash]staticAddressLoopInHighWater { + + highWater := make(map[lntypes.Hash]staticAddressLoopInHighWater) + for _, swp := range swaps { + if swp.SwapType != swap.TypeStaticAddressLoopIn { + continue + } + + current, ok := highWater[swp.SwapHash] + if !ok || swp.LastUpdate.After(current.lastUpdate) { + highWater[swp.SwapHash] = staticAddressLoopInHighWater{ + lastUpdate: swp.LastUpdate, + staticState: swp.StaticAddressLoopInState, + } + } + } + + return highWater +} + +// staticAddressLoopInHighWater stores the most recent snapshot timestamp and +// state for one static-address loop-in swap. +type staticAddressLoopInHighWater struct { + lastUpdate time.Time + staticState fsm.StateType +} + +// isInitialStaticAddressLoopInStale reports whether a live static-address +// loop-in update is older than the snapshot copy already sent, or equal to it +// with the same static FSM state. +func isInitialStaticAddressLoopInStale( + highWater map[lntypes.Hash]staticAddressLoopInHighWater, + swp loop.SwapInfo) bool { + + if swp.SwapType != swap.TypeStaticAddressLoopIn { + return false + } + current, ok := highWater[swp.SwapHash] + if !ok { + return false + } + + if swp.LastUpdate.Before(current.lastUpdate) { + return true + } + if swp.LastUpdate.After(current.lastUpdate) { + return false + } + + // Equal timestamps can race the initial DB snapshot, so match state too. + return swp.StaticAddressLoopInState == current.staticState +} + +// monitorCachedSwaps returns the current in-memory swaps split into pending and +// completed slices for monitor snapshot construction. +func (s *swapClientServer) monitorCachedSwaps() ([]loop.SwapInfo, + []loop.SwapInfo) { + + var pendingSwaps, completedSwaps []loop.SwapInfo + for _, swap := range s.swaps { + if swap.State.Type() == loopdb.StateTypePending { + pendingSwaps = append(pendingSwaps, swap) + } else { + completedSwaps = append(completedSwaps, swap) + } + } + + return pendingSwaps, completedSwaps +} + +// appendStaticAddressLoopInMonitorSnapshot appends the current static-address +// loop-in swaps to the monitor snapshot. +func (s *swapClientServer) appendStaticAddressLoopInMonitorSnapshot( + ctx context.Context, pendingSwaps, completedSwaps *[]loop.SwapInfo) error { + + staticSwaps, err := s.staticAddressLoopInSwapInfos(ctx) + if err != nil { + return err + } + for _, swap := range staticSwaps { + if slices.Contains( + loopin.FinalStates, swap.StaticAddressLoopInState, + ) { + + *completedSwaps = append(*completedSwaps, *swap) + } else { + *pendingSwaps = append(*pendingSwaps, *swap) + } + } + + return nil +} + // ListSwaps returns a list of all currently known swaps and their current // status. func (s *swapClientServer) ListSwaps(ctx context.Context, @@ -754,10 +896,13 @@ func (s *swapClientServer) SwapInfo(ctx context.Context, // Just return the server's in-memory cache here too as we also want to // return temporary failures to the client. + s.swapsLock.Lock() swp, ok := s.swaps[swapHash] + s.swapsLock.Unlock() if !ok { return nil, fmt.Errorf("swap with hash %s not found", req.Id) } + return s.marshallSwap(ctx, &swp) } @@ -2089,6 +2234,8 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, }, nil } +// staticAddressLoopInTimestamp converts a non-zero timestamp to Unix nano +// form and preserves zero timestamps as zero. func staticAddressLoopInTimestamp(t time.Time) int64 { if t.IsZero() { return 0 @@ -2114,6 +2261,141 @@ func staticAddressLoopInSwapServerCost(swp *loopin.StaticAddressLoopIn) int64 { } } +// staticAddressLoopInSwapInfos loads the static-address loop-in manager swaps +// and converts them to client-facing swap info records. +func (s *swapClientServer) staticAddressLoopInSwapInfos( + ctx context.Context) ([]*loop.SwapInfo, error) { + + if s.staticLoopInManager == nil { + return nil, nil + } + + staticSwaps, err := s.staticLoopInManager.GetAllSwaps(ctx) + if err != nil { + return nil, err + } + + swapInfos := make([]*loop.SwapInfo, 0, len(staticSwaps)) + for _, swp := range staticSwaps { + if swp == nil { + continue + } + + swapInfo, err := s.staticAddressLoopInSwapInfo(ctx, swp) + if err != nil { + return nil, err + } + swapInfos = append(swapInfos, swapInfo) + } + + return swapInfos, nil +} + +// staticAddressLoopInSwapInfo converts one static-address loop-in into swap +// info using the daemon's current chain parameters. +func (s *swapClientServer) staticAddressLoopInSwapInfo(_ context.Context, + swp *loopin.StaticAddressLoopIn) (*loop.SwapInfo, error) { + + chainParams, err := s.network.ChainParams() + if err != nil { + return nil, fmt.Errorf("error getting chain params") + } + + return staticAddressLoopInSwapInfoWithChainParams(swp, chainParams) +} + +// staticAddressLoopInSwapInfoWithChainParams converts one static-address +// loop-in into swap info, including its reconstructed V2 P2WSH HTLC address. +func staticAddressLoopInSwapInfoWithChainParams( + swp *loopin.StaticAddressLoopIn, + chainParams *chaincfg.Params) (*loop.SwapInfo, error) { + + htlcAddress, err := staticAddressLoopInHtlcAddress(swp, chainParams) + if err != nil { + return nil, err + } + + var lastHop *route.Vertex + if len(swp.LastHop) > 0 { + vertex, err := route.NewVertexFromBytes(swp.LastHop) + if err != nil { + return nil, err + } + lastHop = &vertex + } + + amount := swp.TotalDepositAmount() + if swp.SelectedAmount > 0 { + amount = swp.SelectedAmount + } + + lastUpdate := swp.LastUpdateTime + if lastUpdate.IsZero() { + lastUpdate = swp.InitiationTime + } + + return &loop.SwapInfo{ + SwapStateData: loopdb.SwapStateData{ + // Mirror ListStaticAddressSwaps by reporting only the persisted + // client-visible server cost. On-chain and off-chain costs stay + // zero until static loop-ins persist real fee data. + Cost: loopdb.SwapCost{ + Server: btcutil.Amount( + staticAddressLoopInSwapServerCost(swp), + ), + }, + }, + SwapContract: loopdb.SwapContract{ + AmountRequested: amount, + CltvExpiry: swp.HtlcCltvExpiry, + MaxSwapFee: swp.MaxSwapFee, + InitiationTime: swp.InitiationTime, + Label: swp.Label, + ProtocolVersion: loopdb.ProtocolVersion( + swp.ProtocolVersion, + ), + }, + LastUpdate: lastUpdate, + SwapHash: swp.SwapHash, + SwapType: swap.TypeStaticAddressLoopIn, + StaticAddressLoopInState: swp.GetState(), + HtlcAddressP2WSH: htlcAddress, + LastHop: lastHop, + }, nil +} + +// staticAddressLoopInHtlcAddress reconstructs the V2 P2WSH HTLC address from +// the static-address loop-in's client and server keys. +func staticAddressLoopInHtlcAddress(swp *loopin.StaticAddressLoopIn, + chainParams *chaincfg.Params) (btcutil.Address, error) { + + if swp.ClientPubkey == nil { + return nil, errors.New("missing static address loop-in client HTLC key") + } + if swp.ServerPubkey == nil { + return nil, errors.New("missing static address loop-in server HTLC key") + } + + htlc, err := swap.NewHtlcV2( + swp.HtlcCltvExpiry, pubkeyTo33ByteSlice(swp.ClientPubkey), + pubkeyTo33ByteSlice(swp.ServerPubkey), swp.SwapHash, chainParams, + ) + if err != nil { + return nil, fmt.Errorf("construct static address loop-in HTLC: %w", err) + } + + return htlc.Address, nil +} + +// pubkeyTo33ByteSlice converts a compressed public key to a fixed 33-byte +// array. +func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte { + var pubkeyBytes [33]byte + copy(pubkeyBytes[:], pubkey.SerializeCompressed()) + + return pubkeyBytes +} + // GetStaticAddressSummary returns a summary of static address-related // information. Amongst deposits and withdrawals and their total values, it also // includes a list of detailed deposit information filtered by their state. @@ -2420,6 +2702,8 @@ func toClientDepositState(state fsm.StateType) looprpc.DepositState { } } +// toClientStaticAddressLoopInState maps the static-address loop-in FSM state +// to the RPC enum exposed to clients. func toClientStaticAddressLoopInState( state fsm.StateType) looprpc.StaticAddressLoopInSwapState { @@ -2572,7 +2856,12 @@ func (s *swapClientServer) processStatusUpdates(mainCtx context.Context) { // subscribers about the changes. case swp := <-s.statusChan: s.swapsLock.Lock() - s.swaps[swp.SwapHash] = swp + // Static loop-ins are broadcast to monitor subscribers, but they + // stay out of the legacy swap cache so ListSwaps and SwapInfo remain + // traditional-swap views. + if swp.SwapType != swap.TypeStaticAddressLoopIn { + s.swaps[swp.SwapHash] = swp + } for _, subscriber := range s.subscribers { select { diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index f3fab032..03f3d95a 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -25,6 +26,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" @@ -32,6 +34,7 @@ import ( "github.com/lightningnetwork/lnd/routing/route" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) @@ -501,10 +504,554 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { ) } +// TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH protects the RPC +// mapping invariant that static loop-ins expose their static type, static +// state, and P2WSH HTLC address without leaking a taproot HTLC address. +func TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH(t *testing.T) { + server := &swapClientServer{} + loopSwap := &loop.SwapInfo{ + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateInitiated, + }, + SwapContract: loopdb.SwapContract{ + InitiationTime: time.Now(), + }, + LastUpdate: time.Now(), + SwapHash: lntypes.Hash{1}, + SwapType: swap.TypeStaticAddressLoopIn, + StaticAddressLoopInState: loopin.SignHtlcTx, + HtlcAddressP2WSH: testnetAddr, + } + + rpcSwap, err := server.marshallSwap(t.Context(), loopSwap) + require.NoError(t, err) + require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, rpcSwap.Type) + require.Equal( + t, looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX, + rpcSwap.GetStaticLoopInState(), + ) + require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State) + require.Equal(t, testnetAddr.EncodeAddress(), rpcSwap.HtlcAddressP2Wsh) + require.Empty(t, rpcSwap.HtlcAddressP2Tr) +} + +// TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault asserts that +// static loop-in failures keep default legacy fields while preserving the +// precise static state. +func TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault( + t *testing.T) { + + tests := []struct { + name string + state fsm.StateType + wantStaticState looprpc.StaticAddressLoopInSwapState + }{ + { + name: "failed", + state: loopin.Failed, + wantStaticState: looprpc. + StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP, + }, + { + name: "succeeded transitioning failed", + state: loopin.SucceededTransitioningFailed, + wantStaticState: looprpc. + StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server, staticLoopIn := newGenericStaticLoopInServer(t) + staticLoopIn.SetState(test.state) + loopSwap, err := server.staticAddressLoopInSwapInfo( + t.Context(), staticLoopIn, + ) + require.NoError(t, err) + + rpcSwap, err := server.marshallSwap(t.Context(), loopSwap) + + require.NoError(t, err) + require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State) + require.Equal( + t, looprpc.FailureReason_FAILURE_REASON_NONE, + rpcSwap.FailureReason, + ) + require.Equal( + t, test.wantStaticState, + rpcSwap.GetStaticLoopInState(), + ) + }) + } +} + +// TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress protects the +// fail-closed HTLC-address invariant for static loop-ins missing the P2WSH +// address required by the client-facing RPC representation. +func TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress(t *testing.T) { + _, taprootAddress := newTestStaticAddressParams(t) + server := &swapClientServer{} + loopSwap := &loop.SwapInfo{ + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateInitiated, + }, + SwapContract: loopdb.SwapContract{ + InitiationTime: time.Now(), + }, + LastUpdate: time.Now(), + SwapHash: lntypes.Hash{1}, + SwapType: swap.TypeStaticAddressLoopIn, + StaticAddressLoopInState: loopin.SignHtlcTx, + HtlcAddressP2TR: taprootAddress, + } + + _, err := server.marshallSwap(t.Context(), loopSwap) + require.ErrorContains(t, err, "missing static address loop-in P2WSH HTLC address") +} + +// TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing protects the +// HTLC-address construction invariant that missing cooperative keys must not +// produce monitorable swap info. +func TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing(t *testing.T) { + server, staticLoopIn := newGenericStaticLoopInServer(t) + staticLoopIn.ClientPubkey = nil + + _, err := server.staticAddressLoopInSwapInfo(t.Context(), staticLoopIn) + require.ErrorContains( + t, err, "missing static address loop-in client HTLC key", + ) +} + +// TestMonitorSnapshotIncludesStaticAddressLoopIns protects the monitor snapshot +// invariant that pending static loop-ins are included alongside cached generic +// swaps with their static state and swap-specific HTLC address. +func TestMonitorSnapshotIncludesStaticAddressLoopIns(t *testing.T) { + ctx := t.Context() + server, staticLoopIn := newGenericStaticLoopInServer(t) + + pendingSwaps, completedSwaps := server.monitorCachedSwaps() + err := server.appendStaticAddressLoopInMonitorSnapshot( + ctx, &pendingSwaps, &completedSwaps, + ) + require.NoError(t, err) + require.Empty(t, completedSwaps) + require.Len(t, pendingSwaps, 1) + require.Equal(t, staticLoopIn.SwapHash, pendingSwaps[0].SwapHash) + require.Equal(t, swap.TypeStaticAddressLoopIn, pendingSwaps[0].SwapType) + require.Equal( + t, staticLoopIn.GetState(), + pendingSwaps[0].StaticAddressLoopInState, + ) + assertStaticLoopInUsesSwapHtlcAddress(t, staticLoopIn, pendingSwaps[0]) +} + +// TestMonitorSnapshotIncludesFinalStaticAddressLoopIns protects the monitor +// snapshot invariant that exact final static loop-in states are completed swaps. +func TestMonitorSnapshotIncludesFinalStaticAddressLoopIns(t *testing.T) { + server, staticLoopIn := newGenericStaticLoopInServer(t) + staticLoopIn.SetState(loopin.Succeeded) + + pendingSwaps, completedSwaps := server.monitorCachedSwaps() + err := server.appendStaticAddressLoopInMonitorSnapshot( + t.Context(), &pendingSwaps, &completedSwaps, + ) + + require.NoError(t, err) + require.Empty(t, pendingSwaps) + require.Len(t, completedSwaps, 1) +} + +// TestStaticLoopInStatusUpdaterUsesSwapHtlcAddress protects the live-update +// invariant that static loop-in status events derive the HTLC address from the +// swap, not from reusable static address parameters. +func TestStaticLoopInStatusUpdaterUsesSwapHtlcAddress(t *testing.T) { + ctx := t.Context() + _, staticLoopIn := newGenericStaticLoopInServer(t) + staticLoopIn.AddressParams = nil + statusChan := make(chan loop.SwapInfo, 1) + updater := &staticLoopInStatusUpdater{ + statusChan: statusChan, + mainCtx: ctx, + chainParams: &chaincfg.TestNet3Params, + } + + err := updater.sendUpdate(ctx, staticLoopIn) + require.NoError(t, err) + swapInfo := <-statusChan + assertStaticLoopInUsesSwapHtlcAddress(t, staticLoopIn, swapInfo) +} + +// TestMonitorSuppressesStaticAddressLoopInSnapshotLiveDuplicate protects the +// monitor race invariant that live static loop-in updates arriving during the +// initial snapshot are deduplicated without dropping newer progress. +func TestMonitorSuppressesStaticAddressLoopInSnapshotLiveDuplicate(t *testing.T) { + logger := btclog.NewSLogger( + btclog.NewDefaultHandler(os.Stdout), + ) + setLogger(logger.SubSystem(Subsystem)) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mainCtx, mainCancel := context.WithCancel(t.Context()) + defer mainCancel() + server, staticLoopIn, store := newGenericStaticLoopInServerWithStore(t) + server.statusChan = make(chan loop.SwapInfo) + server.subscribers = make(map[int]chan<- any) + server.mainCtx = mainCtx + + snapshotStarted := make(chan struct{}, 1) + releaseSnapshot := make(chan struct{}) + store.beforeGet = func() { + select { + case snapshotStarted <- struct{}{}: + default: + } + } + store.waitGet = releaseSnapshot + + go server.processStatusUpdates(mainCtx) + + monitorServer := &testMonitorServer{ + ctx: ctx, + sent: make(chan *looprpc.SwapStatus, 3), + } + errChan := make(chan error, 1) + go func() { + errChan <- server.Monitor(&looprpc.MonitorRequest{}, monitorServer) + }() + + select { + case <-snapshotStarted: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + + staticUpdate, err := server.staticAddressLoopInSwapInfo(ctx, staticLoopIn) + require.NoError(t, err) + staleUpdate := *staticUpdate + staleUpdate.State = loopdb.StateInitiated + staleUpdate.LastUpdate = staticUpdate.LastUpdate.Add(-time.Second) + server.statusChan <- staleUpdate + + server.statusChan <- *staticUpdate + close(releaseSnapshot) + + first := receiveMonitorUpdate(t, ctx, monitorServer.sent) + require.Equal(t, staticLoopIn.SwapHash[:], first.IdBytes) + require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, first.Type) + require.Equal( + t, looprpc.StaticAddressLoopInSwapState_PAYMENT_RECEIVED, + first.GetStaticLoopInState(), + ) + + nextUpdate := *staticUpdate + nextUpdate.State = loopdb.StateSuccess + nextUpdate.StaticAddressLoopInState = loopin.Succeeded + nextUpdate.LastUpdate = staticUpdate.LastUpdate.Add(time.Second) + server.statusChan <- nextUpdate + + second := receiveMonitorUpdate(t, ctx, monitorServer.sent) + require.Equal(t, staticLoopIn.SwapHash[:], second.IdBytes) + require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, second.Type) + require.Equal( + t, looprpc.StaticAddressLoopInSwapState_SUCCEEDED, + second.GetStaticLoopInState(), + ) + + cancel() + require.NoError(t, <-errChan) +} + +// TestStaticAddressLoopInHighWaterSuppressesExactDuplicate protects the +// high-water dedup invariant that an initial live update identical to the +// snapshot is treated as stale. +func TestStaticAddressLoopInHighWaterSuppressesExactDuplicate(t *testing.T) { + swapHash := lntypes.Hash{1, 2, 3} + lastUpdate := time.Unix(100, 0).UTC() + snapshot := loop.SwapInfo{ + SwapHash: swapHash, + SwapType: swap.TypeStaticAddressLoopIn, + LastUpdate: lastUpdate, + StaticAddressLoopInState: loopin.PaymentReceived, + } + highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{ + snapshot, + }) + + isStale := isInitialStaticAddressLoopInStale(highWater, snapshot) + + require.True(t, isStale) +} + +// TestStaticAddressLoopInHighWaterKeepsSameTimeDifferentState protects the +// high-water timing invariant that equal timestamps do not hide a distinct +// static loop-in state transition. +func TestStaticAddressLoopInHighWaterKeepsSameTimeDifferentState(t *testing.T) { + swapHash := lntypes.Hash{1, 2, 3} + lastUpdate := time.Unix(100, 0).UTC() + snapshot := loop.SwapInfo{ + SwapHash: swapHash, + SwapType: swap.TypeStaticAddressLoopIn, + LastUpdate: lastUpdate, + StaticAddressLoopInState: loopin.PaymentReceived, + } + liveUpdate := snapshot + liveUpdate.StaticAddressLoopInState = loopin.Succeeded + highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{ + snapshot, + }) + + isStale := isInitialStaticAddressLoopInStale(highWater, liveUpdate) + + require.False(t, isStale) +} + +// TestStaticAddressLoopInHighWaterSuppressesOlderStaticOnly protects the +// high-water cache invariant that stale suppression applies only to static +// loop-ins and cannot filter generic swap updates. +func TestStaticAddressLoopInHighWaterSuppressesOlderStaticOnly(t *testing.T) { + swapHash := lntypes.Hash{1, 2, 3} + lastUpdate := time.Unix(100, 0).UTC() + snapshot := loop.SwapInfo{ + SwapHash: swapHash, + SwapType: swap.TypeStaticAddressLoopIn, + LastUpdate: lastUpdate, + StaticAddressLoopInState: loopin.PaymentReceived, + } + highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{ + snapshot, + }) + olderStatic := snapshot + olderStatic.LastUpdate = lastUpdate.Add(-time.Second) + olderStatic.StaticAddressLoopInState = loopin.Succeeded + nonStatic := olderStatic + nonStatic.SwapType = swap.TypeOut + + staticStale := isInitialStaticAddressLoopInStale(highWater, olderStatic) + nonStaticStale := isInitialStaticAddressLoopInStale( + highWater, nonStatic, + ) + + require.True(t, staticStale) + require.False(t, nonStaticStale) +} + +// TestStaticAddressLoopInStatusUpdateDoesNotEnterGenericSwapCache protects the +// cache isolation invariant that static loop-in live updates reach subscribers +// without entering the generic swap cache. +func TestStaticAddressLoopInStatusUpdateDoesNotEnterGenericSwapCache(t *testing.T) { + ctx := t.Context() + server, staticLoopIn := newGenericStaticLoopInServer(t) + server.statusChan = make(chan loop.SwapInfo) + updates := make(chan any, 1) + server.subscribers = map[int]chan<- any{0: updates} + mainCtx, cancel := context.WithCancel(ctx) + defer cancel() + go server.processStatusUpdates(mainCtx) + + staticUpdate, err := server.staticAddressLoopInSwapInfo(ctx, staticLoopIn) + require.NoError(t, err) + server.statusChan <- *staticUpdate + + select { + case update := <-updates: + require.Equal(t, staticLoopIn.SwapHash, update.(loop.SwapInfo).SwapHash) + + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + + server.swapsLock.Lock() + _, cached := server.swaps[staticLoopIn.SwapHash] + server.swapsLock.Unlock() + require.False(t, cached) +} + +func newGenericStaticLoopInServer(t *testing.T) (*swapClientServer, + *loopin.StaticAddressLoopIn) { + + server, staticLoopIn, _ := newGenericStaticLoopInServerWithStore(t) + + return server, staticLoopIn +} + +func newTestStaticAddressParams(t *testing.T) (*script.Parameters, + *btcutil.AddressTaproot) { + + t.Helper() + + const staticAddressExpiry = uint32(25) + + _, staticClientPubkey := mock_lnd.CreateKey(12) + _, staticServerPubkey := mock_lnd.CreateKey(13) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(staticAddressExpiry), + staticClientPubkey, staticServerPubkey, + ) + require.NoError(t, err) + + staticPkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + taprootAddress, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(staticAddress.TaprootKey), + &chaincfg.TestNet3Params, + ) + require.NoError(t, err) + + return &script.Parameters{ + ClientPubkey: staticClientPubkey, + ServerPubkey: staticServerPubkey, + Expiry: staticAddressExpiry, + PkScript: staticPkScript, + }, taprootAddress +} + +func newGenericStaticLoopInServerWithStore(t *testing.T) (*swapClientServer, + *loopin.StaticAddressLoopIn, *mockStaticAddressLoopInStore) { + + t.Helper() + + _, clientPubkey := mock_lnd.CreateKey(10) + _, serverPubkey := mock_lnd.CreateKey(11) + addressParams, _ := newTestStaticAddressParams(t) + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{12, 13, 14}, + Index: 2, + } + staticDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + Value: 51_000, + } + lastHop := route.Vertex{7, 8, 9} + + staticLoopIn := &loopin.StaticAddressLoopIn{ + SwapHash: lntypes.Hash{1, 2, 3}, + HtlcCltvExpiry: 700, + InitiationTime: time.Unix(100, 0).UTC(), + LastUpdateTime: time.Unix(200, 0).UTC(), + Label: "static-loop-in", + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + LastHop: lastHop[:], + QuotedSwapFee: 1_111, + SelectedAmount: 50_000, + DepositOutpoints: []string{depositOutpoint.String()}, + Deposits: []*deposit.Deposit{staticDeposit}, + AddressParams: addressParams, + } + staticLoopIn.SetState(loopin.PaymentReceived) + + depositStore := &mockDepositStore{ + byOutpoint: map[string]*deposit.Deposit{ + depositOutpoint.String(): staticDeposit, + }, + } + loopInStore := &mockStaticAddressLoopInStore{ + swaps: []*loopin.StaticAddressLoopIn{staticLoopIn}, + } + staticLoopInManager, err := loopin.NewManager(&loopin.Config{ + Store: loopInStore, + DepositManager: deposit.NewManager(&deposit.ManagerConfig{ + Store: depositStore, + }), + }, 1) + require.NoError(t, err) + + return &swapClientServer{ + network: lndclient.NetworkTestnet, + swaps: make(map[lntypes.Hash]loop.SwapInfo), + staticLoopInManager: staticLoopInManager, + }, staticLoopIn, loopInStore +} + +// assertStaticLoopInUsesSwapHtlcAddress verifies the static loop-in uses the +// swap HTLC P2WSH address expected by the fixture. +func assertStaticLoopInUsesSwapHtlcAddress(t *testing.T, + staticLoopIn *loopin.StaticAddressLoopIn, swapInfo loop.SwapInfo) { + + t.Helper() + + expectedAddress, err := staticAddressLoopInHtlcAddress( + staticLoopIn, &chaincfg.TestNet3Params, + ) + require.NoError(t, err) + require.Nil(t, swapInfo.HtlcAddressP2TR) + require.NotNil(t, swapInfo.HtlcAddressP2WSH) + require.Equal( + t, expectedAddress.EncodeAddress(), + swapInfo.HtlcAddressP2WSH.EncodeAddress(), + ) +} + +// testMonitorServer implements the monitor stream interface for tests. +type testMonitorServer struct { + ctx context.Context + sent chan *looprpc.SwapStatus +} + +// Send forwards monitor updates to the test channel until the context is canceled. +func (s *testMonitorServer) Send(swapStatus *looprpc.SwapStatus) error { + select { + case s.sent <- swapStatus: + return nil + + case <-s.ctx.Done(): + return s.ctx.Err() + } +} + +// SetHeader is a no-op stub that satisfies the monitor stream interface in tests. +func (s *testMonitorServer) SetHeader(metadata.MD) error { + return nil +} + +// SendHeader is a no-op stub that satisfies the monitor stream interface in tests. +func (s *testMonitorServer) SendHeader(metadata.MD) error { + return nil +} + +// SetTrailer is a no-op stub that satisfies the monitor stream interface in tests. +func (s *testMonitorServer) SetTrailer(metadata.MD) {} + +// Context returns the stream context used by the test monitor server. +func (s *testMonitorServer) Context() context.Context { + return s.ctx +} + +// SendMsg is a no-op stub that satisfies the monitor stream interface in tests. +func (s *testMonitorServer) SendMsg(any) error { + return nil +} + +// RecvMsg is a no-op stub that satisfies the monitor stream interface in tests. +func (s *testMonitorServer) RecvMsg(any) error { + return nil +} + +// receiveMonitorUpdate waits for a monitor update or fails if the context is canceled. +func receiveMonitorUpdate(t *testing.T, ctx context.Context, + updates <-chan *looprpc.SwapStatus) *looprpc.SwapStatus { + + t.Helper() + + select { + case update := <-updates: + return update + + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + + return nil +} + // mockStaticAddressLoopInStore is a minimal in-memory loop-in store for RPC // response mapping tests. type mockStaticAddressLoopInStore struct { - swaps []*loopin.StaticAddressLoopIn + swaps []*loopin.StaticAddressLoopIn + beforeGet func() + waitGet <-chan struct{} } // CreateLoopIn satisfies the static loop-in store interface. @@ -523,9 +1070,20 @@ func (s *mockStaticAddressLoopInStore) UpdateLoopIn(_ context.Context, // GetStaticAddressLoopInSwapsByStates returns the configured loop-ins. func (s *mockStaticAddressLoopInStore) GetStaticAddressLoopInSwapsByStates( - _ context.Context, _ []fsm.StateType) ([]*loopin.StaticAddressLoopIn, + ctx context.Context, _ []fsm.StateType) ([]*loopin.StaticAddressLoopIn, error) { + if s.beforeGet != nil { + s.beforeGet() + } + if s.waitGet != nil { + select { + case <-s.waitGet: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return s.swaps, nil } diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 3305bfcd..ec324648 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -80,6 +80,8 @@ const ( SwapType_LOOP_OUT SwapType = 0 // LOOP_IN indicates a loop in swap (on-chain to off-chain) SwapType_LOOP_IN SwapType = 1 + // STATIC_LOOP_IN indicates a static address loop in swap. + SwapType_STATIC_LOOP_IN SwapType = 2 ) // Enum value maps for SwapType. @@ -87,10 +89,12 @@ var ( SwapType_name = map[int32]string{ 0: "LOOP_OUT", 1: "LOOP_IN", + 2: "STATIC_LOOP_IN", } SwapType_value = map[string]int32{ - "LOOP_OUT": 0, - "LOOP_IN": 1, + "LOOP_OUT": 0, + "LOOP_IN": 1, + "STATIC_LOOP_IN": 2, } ) @@ -1474,8 +1478,12 @@ type SwapStatus struct { IdBytes []byte `protobuf:"bytes,11,opt,name=id_bytes,json=idBytes,proto3" json:"id_bytes,omitempty"` // The type of the swap. Type SwapType `protobuf:"varint,3,opt,name=type,proto3,enum=looprpc.SwapType" json:"type,omitempty"` - // State the swap is currently in, see State enum. + // Generic loop-in/loop-out state for swaps. State SwapState `protobuf:"varint,4,opt,name=state,proto3,enum=looprpc.SwapState" json:"state,omitempty"` + // Types that are valid to be assigned to StaticLoopInStateOptional: + // + // *SwapStatus_StaticLoopInState + StaticLoopInStateOptional isSwapStatus_StaticLoopInStateOptional `protobuf_oneof:"static_loop_in_state_optional"` // A failure reason for the swap, only set if the swap has failed. FailureReason FailureReason `protobuf:"varint,14,opt,name=failure_reason,json=failureReason,proto3,enum=looprpc.FailureReason" json:"failure_reason,omitempty"` // Initiation time of the swap. @@ -1578,6 +1586,22 @@ func (x *SwapStatus) GetState() SwapState { return SwapState_INITIATED } +func (x *SwapStatus) GetStaticLoopInStateOptional() isSwapStatus_StaticLoopInStateOptional { + if x != nil { + return x.StaticLoopInStateOptional + } + return nil +} + +func (x *SwapStatus) GetStaticLoopInState() StaticAddressLoopInSwapState { + if x != nil { + if x, ok := x.StaticLoopInStateOptional.(*SwapStatus_StaticLoopInState); ok { + return x.StaticLoopInState + } + } + return StaticAddressLoopInSwapState_UNKNOWN_STATIC_ADDRESS_SWAP_STATE +} + func (x *SwapStatus) GetFailureReason() FailureReason { if x != nil { return x.FailureReason @@ -1670,6 +1694,17 @@ func (x *SwapStatus) GetAssetInfo() *AssetLoopOutInfo { return nil } +type isSwapStatus_StaticLoopInStateOptional interface { + isSwapStatus_StaticLoopInStateOptional() +} + +type SwapStatus_StaticLoopInState struct { + // Static address loop-in FSM state when type is STATIC_LOOP_IN. + StaticLoopInState StaticAddressLoopInSwapState `protobuf:"varint,20,opt,name=static_loop_in_state,json=staticLoopInState,proto3,enum=looprpc.StaticAddressLoopInSwapState,oneof"` +} + +func (*SwapStatus_StaticLoopInState) isSwapStatus_StaticLoopInStateOptional() {} + type ListSwapsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Optional filter to only return swaps that match the filter. @@ -6704,14 +6739,15 @@ const file_client_proto_rawDesc = "" + "\x12htlc_address_p2wsh\x18\x05 \x01(\tR\x10htlcAddressP2wsh\x12*\n" + "\x11htlc_address_p2tr\x18\a \x01(\tR\x0fhtlcAddressP2tr\x12%\n" + "\x0eserver_message\x18\x06 \x01(\tR\rserverMessageJ\x04\b\x04\x10\x05\"\x10\n" + - "\x0eMonitorRequest\"\xb1\x05\n" + + "\x0eMonitorRequest\"\xac\x06\n" + "\n" + "SwapStatus\x12\x10\n" + "\x03amt\x18\x01 \x01(\x03R\x03amt\x12\x12\n" + "\x02id\x18\x02 \x01(\tB\x02\x18\x01R\x02id\x12\x19\n" + "\bid_bytes\x18\v \x01(\fR\aidBytes\x12%\n" + "\x04type\x18\x03 \x01(\x0e2\x11.looprpc.SwapTypeR\x04type\x12(\n" + - "\x05state\x18\x04 \x01(\x0e2\x12.looprpc.SwapStateR\x05state\x12=\n" + + "\x05state\x18\x04 \x01(\x0e2\x12.looprpc.SwapStateR\x05state\x12X\n" + + "\x14static_loop_in_state\x18\x14 \x01(\x0e2%.looprpc.StaticAddressLoopInSwapStateH\x00R\x11staticLoopInState\x12=\n" + "\x0efailure_reason\x18\x0e \x01(\x0e2\x16.looprpc.FailureReasonR\rfailureReason\x12'\n" + "\x0finitiation_time\x18\x05 \x01(\x03R\x0einitiationTime\x12(\n" + "\x10last_update_time\x18\x06 \x01(\x03R\x0elastUpdateTime\x12%\n" + @@ -6727,7 +6763,8 @@ const file_client_proto_rawDesc = "" + "\x11outgoing_chan_set\x18\x11 \x03(\x04R\x0foutgoingChanSet\x12\x14\n" + "\x05label\x18\x0f \x01(\tR\x05label\x128\n" + "\n" + - "asset_info\x18\x13 \x01(\v2\x19.looprpc.AssetLoopOutInfoR\tassetInfo\"s\n" + + "asset_info\x18\x13 \x01(\v2\x19.looprpc.AssetLoopOutInfoR\tassetInfoB\x1f\n" + + "\x1dstatic_loop_in_state_optional\"s\n" + "\x10ListSwapsRequest\x12B\n" + "\x10list_swap_filter\x18\x01 \x01(\v2\x18.looprpc.ListSwapsFilterR\x0elistSwapFilter\x12\x1b\n" + "\tmax_swaps\x18\x02 \x01(\x04R\bmaxSwaps\"\xf1\x02\n" + @@ -7088,10 +7125,11 @@ const file_client_proto_rawDesc = "" + "\x13asset_cost_offchain\x18\x03 \x01(\x04R\x11assetCostOffchain*;\n" + "\vAddressType\x12\x18\n" + "\x14ADDRESS_TYPE_UNKNOWN\x10\x00\x12\x12\n" + - "\x0eTAPROOT_PUBKEY\x10\x01*%\n" + + "\x0eTAPROOT_PUBKEY\x10\x01*9\n" + "\bSwapType\x12\f\n" + "\bLOOP_OUT\x10\x00\x12\v\n" + - "\aLOOP_IN\x10\x01*s\n" + + "\aLOOP_IN\x10\x01\x12\x12\n" + + "\x0eSTATIC_LOOP_IN\x10\x02*s\n" + "\tSwapState\x12\r\n" + "\tINITIATED\x10\x00\x12\x15\n" + "\x11PREIMAGE_REVEALED\x10\x01\x12\x12\n" + @@ -7321,121 +7359,122 @@ var file_client_proto_depIdxs = []int32{ 91, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState - 3, // 7: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason - 88, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo - 20, // 9: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter - 9, // 10: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter - 18, // 11: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus - 24, // 12: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested - 25, // 13: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded - 26, // 14: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 91, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 85, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 86, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 91, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 40, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 41, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 41, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 47, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule - 0, // 23: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 89, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry - 4, // 25: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource - 1, // 26: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType - 5, // 27: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 45, // 28: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters - 6, // 29: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason - 14, // 30: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest - 15, // 31: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 83, // 32: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest - 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 + 8, // 7: looprpc.SwapStatus.static_loop_in_state:type_name -> looprpc.StaticAddressLoopInSwapState + 3, // 8: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason + 88, // 9: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo + 20, // 10: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter + 9, // 11: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter + 18, // 12: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus + 24, // 13: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested + 25, // 14: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded + 26, // 15: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed + 91, // 16: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 85, // 17: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 86, // 18: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 91, // 19: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 40, // 20: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 41, // 21: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 41, // 22: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 47, // 23: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 0, // 24: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType + 89, // 25: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 4, // 26: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource + 1, // 27: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType + 5, // 28: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType + 45, // 29: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 6, // 30: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason + 14, // 31: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest + 15, // 32: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest + 83, // 33: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest + 51, // 34: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 57, // 35: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 64, // 36: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 69, // 37: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 92, // 38: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 39: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 40: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 41: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 42: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 43: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 44: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 45: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 46: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 91, // 47: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 48: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 49: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 50: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 51: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 52: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 53: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 54: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 55: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 56: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 57: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 58: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 59: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 60: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 61: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 62: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 63: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 64: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 65: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 66: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 67: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 68: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 69: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 70: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 71: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 72: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 73: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 74: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 75: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 76: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 77: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 78: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 79: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 80: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 81: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 82: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 83: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 84: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 85: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 86: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 87: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 88: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 89: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 90: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 91: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 92: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 93: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 94: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 95: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 96: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 97: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 98: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 99: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 100: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 101: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 102: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 103: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 104: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 105: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 106: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 107: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 108: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 109: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 110: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 111: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 112: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 113: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 114: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 115: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 116: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 117: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 85, // [85:118] is the sub-list for method output_type + 52, // [52:85] is the sub-list for method input_type + 52, // [52:52] is the sub-list for extension type_name + 52, // [52:52] is the sub-list for extension extendee + 0, // [0:52] is the sub-list for field type_name } func init() { file_client_proto_init() } @@ -7443,6 +7482,9 @@ func file_client_proto_init() { if File_client_proto != nil { return } + file_client_proto_msgTypes[8].OneofWrappers = []any{ + (*SwapStatus_StaticLoopInState)(nil), + } file_client_proto_msgTypes[13].OneofWrappers = []any{ (*SweepHtlcResponse_NotRequested)(nil), (*SweepHtlcResponse_Published)(nil), diff --git a/looprpc/client.proto b/looprpc/client.proto index 3ae690dc..844dade7 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -546,10 +546,17 @@ message SwapStatus { SwapType type = 3; /* - State the swap is currently in, see State enum. + Generic loop-in/loop-out state for swaps. */ SwapState state = 4; + oneof static_loop_in_state_optional { + /* + Static address loop-in FSM state when type is STATIC_LOOP_IN. + */ + StaticAddressLoopInSwapState static_loop_in_state = 20; + } + /* A failure reason for the swap, only set if the swap has failed. */ @@ -608,6 +615,9 @@ enum SwapType { // LOOP_IN indicates a loop in swap (on-chain to off-chain) LOOP_IN = 1; + + // STATIC_LOOP_IN indicates a static address loop in swap. + STATIC_LOOP_IN = 2; } enum SwapState { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index a50814e4..1c94febb 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -3061,7 +3061,11 @@ }, "state": { "$ref": "#/definitions/looprpcSwapState", - "description": "State the swap is currently in, see State enum." + "description": "Generic loop-in/loop-out state for swaps." + }, + "static_loop_in_state": { + "$ref": "#/definitions/looprpcStaticAddressLoopInSwapState", + "description": "Static address loop-in FSM state when type is STATIC_LOOP_IN." }, "failure_reason": { "$ref": "#/definitions/looprpcFailureReason", @@ -3131,10 +3135,11 @@ "type": "string", "enum": [ "LOOP_OUT", - "LOOP_IN" + "LOOP_IN", + "STATIC_LOOP_IN" ], "default": "LOOP_OUT", - "title": "- LOOP_OUT: LOOP_OUT indicates an loop out swap (off-chain to on-chain)\n - LOOP_IN: LOOP_IN indicates a loop in swap (on-chain to off-chain)" + "description": " - LOOP_OUT: LOOP_OUT indicates an loop out swap (off-chain to on-chain)\n - LOOP_IN: LOOP_IN indicates a loop in swap (on-chain to off-chain)\n - STATIC_LOOP_IN: STATIC_LOOP_IN indicates a static address loop in swap." }, "looprpcSweepHtlcRequest": { "type": "object", diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 1c432e6f..3dc1c029 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -341,6 +341,11 @@ func (f *FSM) InitHtlcAction(ctx context.Context, // Once the swap is stored, restart/recovery code owns invoice lifecycle. invoiceNeedsCleanup = false + err = f.sendUpdate(ctx) + if err != nil { + f.Errorf("Error sending loop-in update: %v", err) + } + event = OnHtlcInitiated return event diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 20b86251..afc0085d 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -878,6 +878,68 @@ func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints( require.Empty(t, checker.outpoints) } +// TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence protects the +// persistence-first invariant: once the loop-in is stored, a later status +// update failure must not roll back the action or state transition. +func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { + mockLnd := test.NewMockLnd() + _, serverKey := test.CreateKey(22) + + server := &mockStaticAddressServer{ + response: testStaticAddressLoopInResponse( + serverKey.SerializeCompressed(), + ), + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 0, + }, + Value: 500_000, + } + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{dep}, + DepositOutpoints: []string{dep.OutPoint.String()}, + SelectedAmount: dep.Value, + QuotedSwapFee: 1_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: 3_600, + } + + sendUpdateErr := errors.New("status channel blocked") + sendUpdateCalled := false + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + Server: server, + DepositManager: &noopDepositManager{}, + LndClient: mockLnd.Client, + WalletKit: mockLnd.WalletKit, + ChainParams: mockLnd.ChainParams, + Store: &mockStore{}, + ValidateLoopInContract: testValidateLoopInContract, + MaxStaticAddrHtlcFeePercentage: 1, + MaxStaticAddrHtlcBackupFeePercentage: 1, + SendUpdate: func(context.Context, + *StaticAddressLoopIn) error { + + sendUpdateCalled = true + + return sendUpdateErr + }, + }, + loopIn: loopIn, + } + + event := f.InitHtlcAction(t.Context(), nil) + require.Equal(t, OnHtlcInitiated, event) + require.Nil(t, f.LastActionError) + require.True(t, sendUpdateCalled) +} + // mockStaticAddressServer captures static-address loop-in requests in tests. type mockStaticAddressServer struct { swapserverrpc.StaticAddressServerClient diff --git a/staticaddr/loopin/fsm.go b/staticaddr/loopin/fsm.go index ea6e610f..eb53dada 100644 --- a/staticaddr/loopin/fsm.go +++ b/staticaddr/loopin/fsm.go @@ -273,6 +273,22 @@ func (f *FSM) updateLoopIn(ctx context.Context, notification fsm.Notification) { return } + + err = f.sendUpdate(ctx) + if err != nil { + f.Errorf("Error sending loop-in update: %v", err) + } +} + +// sendUpdate publishes the latest loop-in state after it has been persisted. +// The callback must remain lightweight because it runs synchronously with FSM +// state transitions. +func (f *FSM) sendUpdate(ctx context.Context) error { + if f.cfg.SendUpdate == nil { + return nil + } + + return f.cfg.SendUpdate(ctx, f.loopIn) } // isUpdateSkipped returns true if the loop-in should not be updated for the diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 92c4a9f1..47a447fc 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -98,6 +98,9 @@ type Config struct { // request. ValidateLoopInContract ValidateLoopInContract + // SendUpdate publishes a loop-in status update after it is persisted. + SendUpdate func(context.Context, *StaticAddressLoopIn) error + // MaxStaticAddrHtlcFeePercentage is the percentage of the swap amount // that we allow the server to charge for the htlc transaction. // Although highly unlikely, this is a defense against the server diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 564429c6..9fa8587c 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -245,6 +245,45 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { require.Equal(t, selectedDeposit.Value, quoteGetter.amount) } +// TestUpdateLoopInSendsUpdateAfterSuccessfulStoreUpdate protects the +// notification contract: after a successful database update, the manager must +// publish the stored loop-in state to listeners. +func TestUpdateLoopInSendsUpdateAfterSuccessfulStoreUpdate(t *testing.T) { + ctx := t.Context() + swapHash := lntypes.Hash{1, 2, 3} + updates := make(chan *StaticAddressLoopIn, 1) + loopIn := &StaticAddressLoopIn{SwapHash: swapHash} + loopIn.SetState(SignHtlcTx) + + loopInFsm := &FSM{ + cfg: &Config{ + Store: &mockStore{stored: true}, + SendUpdate: func(_ context.Context, + updated *StaticAddressLoopIn) error { + + updates <- updated + + return nil + }, + }, + loopIn: loopIn, + } + + loopInFsm.updateLoopIn(ctx, fsm.Notification{ + PreviousState: SignHtlcTx, + NextState: MonitorInvoiceAndHtlcTx, + }) + + select { + case updated := <-updates: + require.Equal(t, swapHash, updated.SwapHash) + require.Equal(t, MonitorInvoiceAndHtlcTx, updated.GetState()) + + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + // TestHandleLoopInSweepReqRejectsInvalidServerNonce ensures that a malformed // MuSig2 nonce returned by the server is rejected before it reaches the signer. func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) { @@ -501,6 +540,7 @@ type mockStore struct { swaps []*StaticAddressLoopIn loopIns map[lntypes.Hash]*StaticAddressLoopIn mapIDs map[lntypes.Hash][]deposit.ID + stored bool } func (s *mockStore) CreateLoopIn(_ context.Context, @@ -521,7 +561,7 @@ func (s *mockStore) GetStaticAddressLoopInSwapsByStates(_ context.Context, return s.swaps, nil } func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) { - return false, nil + return s.stored, nil } // RecordStaticAddressRiskDecision implements Store for manager tests. diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 8b36dbe4..9dc2a084 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "strings" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -23,6 +24,12 @@ import ( const OutpointSeparator = ";" +// sqlStoreUpdateTime returns the PostgreSQL-compatible timestamp precision used +// for persisted loop-in update metadata. +func sqlStoreUpdateTime(clock clock.Clock) time.Time { + return clock.Now().Truncate(time.Microsecond) +} + var ( // ErrInvalidOutpoint is returned when an outpoint contains the outpoint // separator. @@ -288,13 +295,14 @@ func (s *SqlStore) CreateLoopIn(ctx context.Context, Fast: loopIn.Fast, } + updateTime := sqlStoreUpdateTime(s.clock) updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{ SwapHash: loopIn.SwapHash[:], - UpdateTimestamp: s.clock.Now(), + UpdateTimestamp: updateTime, UpdateState: string(loopIn.GetState()), } - return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), + err := s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), func(q Querier) error { err := q.InsertSwap(ctx, swapArgs) if err != nil { @@ -331,6 +339,13 @@ func (s *SqlStore) CreateLoopIn(ctx context.Context, return q.InsertStaticAddressMetaUpdate(ctx, updateArgs) }, ) + if err != nil { + return err + } + + loopIn.LastUpdateTime = updateTime + + return nil } // UpdateLoopIn updates the loop-in in the database. @@ -351,13 +366,14 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, }, } + updateTime := sqlStoreUpdateTime(s.clock) updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{ SwapHash: loopIn.SwapHash[:], UpdateState: string(loopIn.GetState()), - UpdateTimestamp: s.clock.Now(), + UpdateTimestamp: updateTime, } - return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), + err := s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), func(q Querier) error { err := q.UpdateStaticAddressLoopIn(ctx, updateParams) if err != nil { @@ -367,6 +383,13 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, return q.InsertStaticAddressMetaUpdate(ctx, updateArgs) }, ) + if err != nil { + return err + } + + loopIn.LastUpdateTime = updateTime + + return nil } // RecordStaticAddressRiskDecision stores the server's confirmation-risk diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index ffea0f06..c08d940f 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -252,7 +252,9 @@ func TestCreateLoopIn(t *testing.T) { // Set up test context objects. ctx := t.Context() testDb := loopdb.NewTestDB(t) - testClock := clock.NewTestClock(time.Now()) + createTime := time.Unix(1_717_171_717, 123_456_789).UTC() + expectedCreateTime := createTime.Truncate(time.Microsecond) + testClock := clock.NewTestClock(createTime) defer testDb.Close() depositStore := deposit.NewSqlStore(testDb.BaseDB) @@ -325,6 +327,7 @@ func TestCreateLoopIn(t *testing.T) { err = swapStore.CreateLoopIn(ctx, &swapPending) require.NoError(t, err) + require.Equal(t, expectedCreateTime, swapPending.LastUpdateTime) depositIDs, err := swapStore.DepositIDsForSwapHash( ctx, swapHashPending, @@ -349,6 +352,7 @@ func TestCreateLoopIn(t *testing.T) { require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()}, swap.DepositOutpoints) require.Equal(t, SignHtlcTx, swap.GetState()) + require.Equal(t, swapPending.LastUpdateTime, swap.LastUpdateTime) require.Equal( t, ConfirmationRiskDecisionNone, swap.ConfirmationRiskDecision, @@ -445,14 +449,15 @@ func TestCreateLoopIn(t *testing.T) { err = swapStore.UpdateLoopIn(ctx, &swapPending) require.NoError(t, err) + require.Equal( + t, updateTime.Truncate(time.Microsecond), + swapPending.LastUpdateTime, + ) swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending) require.NoError(t, err) require.Equal(t, Succeeded, swap.GetState()) - require.WithinDuration( - t, updateTime.UTC(), swap.LastUpdateTime.UTC(), - time.Microsecond, - ) + require.Equal(t, swapPending.LastUpdateTime, swap.LastUpdateTime) } // TestGetLoopInByHashOrdersDepositsBySnapshot ensures recovered deposits are diff --git a/swap/type.go b/swap/type.go index 20942c74..b4558e15 100644 --- a/swap/type.go +++ b/swap/type.go @@ -9,10 +9,13 @@ const ( // TypeOut is a loop out swap. TypeOut + + // TypeStaticAddressLoopIn is a static-address loop-in swap. + TypeStaticAddressLoopIn ) -// IsOut returns true if the swap is a loop out swap, false if it is a loop in -// swap. +// IsOut returns true only if the swap is TypeOut; TypeIn and +// TypeStaticAddressLoopIn both return false. func (t Type) IsOut() bool { return t == TypeOut } @@ -23,6 +26,8 @@ func (t Type) String() string { return "In" case TypeOut: return "Out" + case TypeStaticAddressLoopIn: + return "StaticAddressLoopIn" default: return "Unknown" }