Skip to content
5 changes: 4 additions & 1 deletion .claude/skills/waved.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ surfaces the raw waverpc methods underlying them.
# Raw VTXO inventory + lifecycle
wavecli ark vtxos list --no-tls
wavecli ark vtxos list --status live --min_amount 10000 --no-tls
wavecli ark vtxos refresh --all --no-tls
# A real refresh is fee-gated: preview with --dry_run, consent with
# --yes (required on non-interactive stdin).
wavecli ark vtxos refresh --all --dry_run --no-tls
wavecli ark vtxos refresh --all --yes --no-tls

# Raw transaction history (the wallet-shaped feed is `activity`)
wavecli ark listtransactions --no-tls
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ wavecli ark vtxos list --ndjson | jq '.amount_sat'
# Refresh — see BUGS_FOUND.md bug-1/bug-2; the refresh path is
# currently not landing on the operator.
wavecli ark vtxos refresh --outpoint txid:0 --dry_run
wavecli ark vtxos refresh --outpoint txid:0
wavecli ark vtxos refresh --outpoint txid:0 --yes
```

### Send operations
Expand Down
10 changes: 10 additions & 0 deletions cmd/wavecli/waveclicommands/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave
- `recovery escalate` refuses to run on non-interactive stdin unless
`--yes` is passed — it never blocks on a y/N prompt an agent can't
answer.
- `ark vtxos refresh` is gated on fee consent: a real refresh fetches
the dry-run estimate and prompts with it on a TTY, and refuses on
non-interactive stdin without `--yes` (same posture as `leave --all`
and `recovery escalate`). The MCP tool enforces the same contract
through its `yes` argument — no prompt exists there, so a bare real
refresh returns an immediate actionable error. `--dry_run` previews
the itemized advisory estimate and never prompts. A failed estimate
degrades to a "still charged the seal-time fee" warning and its
total is absent on the wire (explicit proto presence) — it never
blocks the flow and is never rendered as a zero fee.

## Deep Docs

Expand Down
10 changes: 10 additions & 0 deletions cmd/wavecli/waveclicommands/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave
- `recovery escalate` refuses to run on non-interactive stdin unless
`--yes` is passed — it never blocks on a y/N prompt an agent can't
answer.
- `ark vtxos refresh` is gated on fee consent: a real refresh fetches
the dry-run estimate and prompts with it on a TTY, and refuses on
non-interactive stdin without `--yes` (same posture as `leave --all`
and `recovery escalate`). The MCP tool enforces the same contract
through its `yes` argument — no prompt exists there, so a bare real
refresh returns an immediate actionable error. `--dry_run` previews
the itemized advisory estimate and never prompts. A failed estimate
degrades to a "still charged the seal-time fee" warning and its
total is absent on the wire (explicit proto presence) — it never
blocks the flow and is never rendered as a zero fee.

## Deep Docs

Expand Down
11 changes: 9 additions & 2 deletions cmd/wavecli/waveclicommands/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,15 @@ func getDaemonConn(cmd *cobra.Command) (*grpc.ClientConn, error) {

// getDaemonClient establishes a gRPC connection to the daemon and returns a
// DaemonServiceClient. The caller is responsible for closing the returned
// connection.
func getDaemonClient(cmd *cobra.Command) (waverpc.DaemonServiceClient,
// connection. Package-level indirection over defaultGetDaemonClient so
// command-wiring tests can substitute an in-process bufconn daemon
// (mirroring the stdinIsTTY indirection); production code never
// overrides it.
var getDaemonClient = defaultGetDaemonClient

// defaultGetDaemonClient dials the daemon from the command's connection
// flags.
func defaultGetDaemonClient(cmd *cobra.Command) (waverpc.DaemonServiceClient,
*grpc.ClientConn, error) {

conn, err := getDaemonConn(cmd)
Expand Down
8 changes: 7 additions & 1 deletion cmd/wavecli/waveclicommands/cmd_fees.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ func newFeesEstimateCmd() *cobra.Command {
"seal time and may differ from this estimate. " +
"Shows liquidity fee, on-chain share, margin, " +
"total fee, effective rate, and minimum " +
"viable VTXO.",
"viable VTXO.\n\n" +
"To preview the fee for refreshing specific " +
"VTXOs without looking up their amounts and " +
"remaining lifetimes by hand, use `ark vtxos " +
"refresh --dry_run` instead: it resolves each " +
"selected VTXO and returns a per-outpoint " +
"estimate.",
RunE: feesEstimate,
}

Expand Down
60 changes: 42 additions & 18 deletions cmd/wavecli/waveclicommands/cmd_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ func buildMCPServer(client waverpc.DaemonServiceClient,
return server
}

// checkMCPRefreshConsent enforces the refresh fee-consent contract on
// the MCP surface: a real (non-dry-run) refresh requires the explicit
// yes acknowledgement, because every refresh is charged an operator
// fee at seal time and MCP has no interactive prompt to fall back to.
// The returned error is immediate and actionable — an agent is never
// blocked, matching the CLI's non-interactive refusal.
func checkMCPRefreshConsent(dryRun, yes bool) error {
if dryRun || yes {
return nil
}

return fmt.Errorf("a refresh is charged an operator fee at seal " +
"time; call with dry_run:true first to preview the itemized " +
"advisory estimate, then pass yes:true to acknowledge the " +
"fee and queue the refresh")
}

// mcpResult builds a CallToolResult from a proto message response.
func mcpResult(msg proto.Message) (*mcp.CallToolResult, error) {
opts := protojson.MarshalOptions{
Expand Down Expand Up @@ -256,31 +273,38 @@ func registerMCPTools(s *mcp.Server, client waverpc.DaemonServiceClient) {

// vtxos_refresh — refresh VTXOs.
type vtxosRefreshArgs struct {
Outpoints []string `json:"outpoints,omitempty" jsonschema:"VTXO outpoint(s) to refresh (txid:index)"` //nolint:ll
All bool `json:"all,omitempty" jsonschema:"refresh all live VTXOs"` //nolint:ll
DryRun bool `json:"dry_run,omitempty" jsonschema:"validate without queuing"` //nolint:ll
Outpoints []string `json:"outpoints,omitempty" jsonschema:"VTXO outpoint(s) to refresh (txid:index)"` //nolint:ll
All bool `json:"all,omitempty" jsonschema:"refresh all live VTXOs"` //nolint:ll
DryRun bool `json:"dry_run,omitempty" jsonschema:"validate without queuing and preview the estimated operator fee"` //nolint:ll
Yes bool `json:"yes,omitempty" jsonschema:"acknowledge the operator fee and queue the refresh (required unless dry_run)"` //nolint:ll
}
mcp.AddTool(s, &mcp.Tool{
Name: "ark.vtxos.refresh",
Description: "Queue VTXOs for refresh in next round",
Name: "ark.vtxos.refresh",
Description: "Queue VTXOs for refresh in next round. A " +
"refresh is charged an operator fee at seal time: " +
"call with dry_run:true first for an itemized " +
"advisory estimate, then pass yes:true to " +
"acknowledge the fee and queue",
}, func(ctx context.Context, req *mcp.CallToolRequest,
args vtxosRefreshArgs) (*mcp.CallToolResult, any, error) {

rpcReq := &waverpc.RefreshVTXOsRequest{
DryRun: args.DryRun,
// Same consent contract as the CLI gate: a real refresh
// needs explicit acknowledgement. The error returns
// immediately (nothing can block on MCP) and tells the
// agent exactly how to proceed.
if err := checkMCPRefreshConsent(
args.DryRun, args.Yes,
); err != nil {
return nil, nil, err
}
if args.All {
rpcReq.Selection = &waverpc.RefreshVTXOsRequest_All{
All: true,
}
} else if len(args.Outpoints) > 0 {
sel := &waverpc.RefreshVTXOsRequest_Outpoints{
Outpoints: &waverpc.OutpointSelection{
Outpoints: args.Outpoints,
},
}
rpcReq.Selection = sel

rpcReq, err := buildRefreshVTXOsRequest(
args.Outpoints, args.All, args.DryRun,
)
if err != nil {
return nil, nil, err
}

resp, err := client.RefreshVTXOs(ctx, rpcReq)
if err != nil {
return nil, nil, err
Expand Down
Loading
Loading