Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion cmd/loop/staticaddr.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var staticAddressCommands = &cli.Command{
Usage: "perform on-chain to off-chain swaps using static addresses.",
Commands: []*cli.Command{
newStaticAddressCommand,
updateStaticAddressLabelCommand,
listUnspentCommand,
listDepositsCommand,
listWithdrawalsCommand,
Expand All @@ -40,6 +41,8 @@ var staticAddressCommands = &cli.Command{
},
}

// newStaticAddressCommand creates a static address and lets operators
// optionally set a local label at creation time.
var newStaticAddressCommand = &cli.Command{
Name: "new",
Aliases: []string{"n"},
Expand All @@ -51,14 +54,24 @@ var newStaticAddressCommand = &cli.Command{
funds can either be cooperatively spent with a signature from the server
or looped in.
`,
Flags: []cli.Flag{
labelFlag,
},
Action: newStaticAddress,
}

// newStaticAddress requests a new static address while keeping the label as
// local operator metadata.
func newStaticAddress(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() > 0 {
return showCommandHelp(ctx, cmd)
}

label := cmd.String(labelFlag.Name)
if err := labels.Validate(label); err != nil {
return err
}

err := displayNewAddressWarning()
if err != nil {
return err
Expand All @@ -71,7 +84,78 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error {
defer cleanup()

resp, err := client.NewStaticAddress(
ctx, &looprpc.NewStaticAddressRequest{},
ctx, &looprpc.NewStaticAddressRequest{
Label: label,
},
)
if err != nil {
return err
}

printRespJSON(resp)

return nil
}

// updateStaticAddressLabelCommand updates local metadata for an existing static
// address so operators can relabel it without affecting the address script or
// Loop server protocol state.
var updateStaticAddressLabelCommand = &cli.Command{
Name: "updatelabel",
Usage: "Update the label for a static address.",
ArgsUsage: "<static_address> <label> | <static_address> --clear",
Description: "Updates the local label for a static address. Use --clear to " +
"remove the label without relying on shell-specific empty-string " +
"arguments.",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "clear",
Usage: "clear the static address label",
},
},
Action: updateStaticAddressLabel,
}

// updateStaticAddressLabel updates the local label for a static address while
// leaving the underlying address and swap protocol data unchanged.
func updateStaticAddressLabel(ctx context.Context, cmd *cli.Command) error {
clearLabel := cmd.Bool("clear")
staticAddress := cmd.Args().Get(0)
label := ""

switch cmd.NArg() {
case 1:
if !clearLabel {
return errors.New("label is required; use --clear to remove it")
}
case 2:
if clearLabel {
return errors.New("cannot specify both label and --clear")
}

label = cmd.Args().Get(1)
if label == "" {
return errors.New("empty label argument requires --clear")
}
default:
return showCommandHelp(ctx, cmd)
}

if err := labels.Validate(label); err != nil {
return err
}

client, cleanup, err := getClient(cmd)
if err != nil {
return err
}
defer cleanup()

resp, err := client.UpdateStaticAddressLabel(
ctx, &looprpc.UpdateStaticAddressLabelRequest{
StaticAddress: staticAddress,
Label: label,
},
)
if err != nil {
return err
Expand Down
248 changes: 248 additions & 0 deletions cmd/loop/staticaddr_test.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
package main

import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/loopin"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)

const staticAddressLabelTestAddress = "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw"

// loopCLIRun describes a single offline CLI replay so label tests can assert
// the exact gRPC payload emitted by the command without requiring a live loopd.
type loopCLIRun struct {
args []string
stdin string
events []grpcPayload
}

// protoEventSpec keeps replay request and response construction data together
// so label tests compare protobuf payloads instead of brittle JSON text.
type protoEventSpec struct {
method string
event string
msg proto.Message
}

// TestLowConfDepositWarningConfirmedOnly verifies confirmed deposits below the
// conservative warning threshold are included in the warning text.
func TestLowConfDepositWarningConfirmedOnly(t *testing.T) {
Expand Down Expand Up @@ -218,3 +242,227 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) {

require.Equal(t, loopInSelectedOutpoints, cliSelected)
}

// TestStaticAddressNewSendsLabel locks the CLI contract that operator labels
// are sent as local metadata when a reusable static address is created.
func TestStaticAddressNewSendsLabel(t *testing.T) {
run := loopCLIRun{
args: []string{
"loop", "static", "new", "--label", "treasury",
"--network", "regtest",
},
stdin: "y\n",
events: []grpcPayload{
requestEvent(
t, "/looprpc.SwapClient/NewStaticAddress",
&looprpc.NewStaticAddressRequest{Label: "treasury"},
),
responseEvent(
t, "/looprpc.SwapClient/NewStaticAddress",
&looprpc.NewStaticAddressResponse{
Address: staticAddressLabelTestAddress,
Expiry: 14400,
Label: "treasury",
},
),
},
}

stdout, err := runLoopCLI(t, run)

require.NoError(t, err)
require.Contains(t, stdout, "CONTINUE WITH NEW ADDRESS")
require.Contains(t, stdout, `"label": "treasury"`)
}

// TestStaticAddressNewRejectsInvalidLabelBeforePromptAndRPC ensures invalid
// operator metadata is rejected locally before the user is prompted or loopd is
// contacted.
func TestStaticAddressNewRejectsInvalidLabelBeforePromptAndRPC(t *testing.T) {
run := loopCLIRun{
args: []string{
"loop", "static", "new", "--label",
labels.Reserved + " static", "--network", "regtest",
},
}

stdout, err := runLoopCLI(t, run)

require.ErrorIs(t, err, labels.ErrReservedPrefix)
require.NotContains(t, stdout, "CONTINUE WITH NEW ADDRESS")
}

// TestStaticAddressUpdateLabelSendsLabel verifies relabeling an existing static
// address only changes the local metadata sent to loopd.
func TestStaticAddressUpdateLabelSendsLabel(t *testing.T) {
run := updateLabelRun(t, "ops", false)

stdout, err := runLoopCLI(t, run)

require.NoError(t, err)
require.Contains(t, stdout, `"label": "ops"`)
}

// TestStaticAddressUpdateLabelClearSendsEmptyLabel verifies clearing metadata is
// represented as an empty label rather than a separate protocol flag.
func TestStaticAddressUpdateLabelClearSendsEmptyLabel(t *testing.T) {
run := updateLabelRun(t, "", true)

stdout, err := runLoopCLI(t, run)

require.NoError(t, err)
require.Contains(t, stdout, `"label": ""`)
}

// TestStaticAddressUpdateLabelRejectsInvalidLabelBeforeRPC prevents reserved
// labels from reaching loopd, preserving the local validation boundary.
func TestStaticAddressUpdateLabelRejectsInvalidLabelBeforeRPC(t *testing.T) {
run := loopCLIRun{
args: []string{
"loop", "static", "updatelabel",
staticAddressLabelTestAddress, labels.Reserved + " static",
"--network", "regtest",
},
}

_, err := runLoopCLI(t, run)

require.ErrorIs(t, err, labels.ErrReservedPrefix)
}

// TestStaticAddressUpdateLabelRejectsLabelAndClear guards the CLI contract that
// clearing and setting metadata are mutually exclusive user intents.
func TestStaticAddressUpdateLabelRejectsLabelAndClear(t *testing.T) {
run := loopCLIRun{
args: []string{
"loop", "static", "updatelabel",
staticAddressLabelTestAddress, "ops", "--clear",
"--network", "regtest",
},
}

_, err := runLoopCLI(t, run)

require.ErrorContains(t, err, "cannot specify both label and --clear")
}

func TestStaticAddressUpdateLabelRequiresLabelOrClear(t *testing.T) {
run := loopCLIRun{
args: []string{
"loop", "static", "updatelabel", staticAddressLabelTestAddress,
"--network", "regtest",
},
}

_, err := runLoopCLI(t, run)

require.ErrorContains(t, err, "label is required; use --clear")
}

// updateLabelRun centralizes the relabel replay fixture so set and clear cases
// assert the same RPC method, address, and response shape.
func updateLabelRun(t *testing.T, label string, clearLabel bool) loopCLIRun {
t.Helper()

args := []string{
"loop", "static", "updatelabel", staticAddressLabelTestAddress,
}
if clearLabel {
args = append(args, "--clear")
} else {
args = append(args, label)
}
args = append(args, "--network", "regtest")

return loopCLIRun{
args: args,
events: []grpcPayload{
requestEvent(
t, "/looprpc.SwapClient/UpdateStaticAddressLabel",
&looprpc.UpdateStaticAddressLabelRequest{
StaticAddress: staticAddressLabelTestAddress,
Label: label,
},
),
responseEvent(
t, "/looprpc.SwapClient/UpdateStaticAddressLabel",
&looprpc.UpdateStaticAddressLabelResponse{
StaticAddress: staticAddressLabelTestAddress,
Label: label,
},
),
},
}
}

// runLoopCLI drives the real command tree through deterministic stdio and gRPC
// replay so label tests exercise user-visible CLI behavior.
func runLoopCLI(t *testing.T, run loopCLIRun) (string, error) {
t.Helper()

var stdout bytes.Buffer
stdoutUnhook, err := hookStdout(os.Stdout, &stdout, nil)
require.NoError(t, err)

stdinUnhook, err := hookStdin(
os.Stdin, strings.NewReader(run.stdin), nil,
)
require.NoError(t, err)

conn := &recordedClientConn{events: run.events}
restoreTransport := hookGrpc(&replayTransport{conn: conn})

previousDeterministic := forceDeterministicJSON
forceDeterministicJSON = true

cmd := newRootCommandForReplay()
runErr := cmd.Run(context.Background(), run.args)

forceDeterministicJSON = previousDeterministic
restoreTransport()
require.NoError(t, stdinUnhook())
require.NoError(t, stdoutUnhook())
require.NoError(t, conn.assertFullyConsumed())

return stdout.String(), runErr
}

// requestEvent records the expected outbound protobuf call so label tests fail
// when the CLI sends the wrong local metadata to loopd.
func requestEvent(t *testing.T, method string, msg proto.Message) grpcPayload {
t.Helper()

return protoEvent(t, protoEventSpec{
method: method,
event: "request",
msg: msg,
})
}

// responseEvent records the daemon reply needed to complete replay without a
// live static-address backend.
func responseEvent(t *testing.T, method string, msg proto.Message) grpcPayload {
t.Helper()

return protoEvent(t, protoEventSpec{
method: method,
event: "response",
msg: msg,
})
}

// protoEvent marshals protobuf fixtures through the same JSON codec used by the
// replay transport so tests compare machine-consumed payloads.
func protoEvent(t *testing.T, spec protoEventSpec) grpcPayload {
t.Helper()

payload, err := protoMarshal.Marshal(spec.msg)
require.NoError(t, err)

return grpcPayload{
Method: spec.method,
Event: spec.event,
MessageType: string(spec.msg.ProtoReflect().Descriptor().FullName()),
Payload: json.RawMessage(payload),
}
}
Loading
Loading