Skip to content

multi: Preview refresh fees and gate refresh on consent - #987

Merged
Roasbeef merged 9 commits into
mainfrom
levmi/refresh-fee-preview
Jul 20, 2026
Merged

multi: Preview refresh fees and gate refresh on consent#987
Roasbeef merged 9 commits into
mainfrom
levmi/refresh-fee-preview

Conversation

@levmi

@levmi levmi commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

In this PR, we fix #986, where ark vtxos refresh queued a fee-incurring refresh with no estimate, warning, or consent anywhere in the flow — the binding seal-time quote is auto-accepted against maxoperatorfeesat, so the user only learned the charge afterwards via the fee history.

A dry-run refresh now returns an itemized advisory fee estimate for the selected VTXOs. The daemon resolves each target's amount and remaining lifetime from its own store and chain tip (no more manual --amount / --remaining-blocks input), fetches the operator's liquidity / on-chain share / margin breakdown through the existing EstimateFee proxy (deduped and validated per quote), and applies the free-late-refresh waiver locally — the operator's EstimateFee prices every refresh as paid, so a selection fully inside the advertised window previews with an explicit zero total while the per-outpoint rows keep the ordinary paid quote to show what the waiver saves (all-or-nothing, mirroring the seal-time isFreeRefresh rule from lightninglabs/lumos#675). Estimate failures degrade all-or-nothing to an estimate_error field rather than failing the preview, and the total uses explicit proto presence so a degraded estimate is absent on the wire, never a zero an agent could misread as free.

A real refresh is now gated on consent on every surface. The CLI mirrors the leave --all pattern: an interactive TTY shows the estimate and prompts (on stderr, preserving the stdout-JSON stream split — the pre-existing leave prompt moves to stderr in its own commit for symmetry), --yes skips the gate for scripted use, and non-interactive stdin refuses with an actionable INVALID_ARGS error instead of blocking on a prompt an agent cannot answer. The MCP tool — which calls the daemon directly and never ran the CLI gate — enforces the same contract through a new yes argument with an immediate, actionable error, and its description now names the fee and the preview-first flow, matching what the schema registry promises.

The dry-run path also becomes an honest validity probe: it moves ahead of the wallet-ready gate for parity with the LeaveVTXOs H-5 ordering (whose comment already claimed this symmetry), unknown and non-live explicit outpoints now fail InvalidArgument instead of echoing back as a plausible preview, repeated outpoints are collapsed so they can neither double-count in the estimate nor register doomed duplicate forfeit pairs, and an empty --all preview honestly reports "preview". The real refresh path is unchanged beyond the shared selection canonicalization, and a new wallet-actor test pins its --all expansion.

Downstream surfaces are unaffected: swapdk-server reads only queued_outpoints + status from refresh responses, and the SDK passes the proto through verbatim. Version skew is handled in both directions — a new CLI against a pre-feature daemon warns that no estimate came back, and old clients simply ignore the new fields. A follow-up issue against lumos for a waiver-aware / outpoint-keyed server-side estimate will be filed once this lands.

See each commit message for a detailed description of the individual changes.

Validation

  • make unit pkg=./waved (also with tags="test_sqlite")
  • make unit pkg=./cmd/wavecli/waveclicommands
  • make unit pkg=./waverpc
  • go test -race ./waved/ -run TestRefresh -count=1 and go test -race ./cmd/wavecli/waveclicommands/ -count=1
  • make fmt-changed-check && make tidy-module-check && make sample-conf-check
  • make lint-changed-local (0 issues)
  • make commitmsg-lint range="origin/main..HEAD"
  • make rpc (regenerated output committed; tree clean on re-run)
  • Every commit builds and vets standalone (verified in a throwaway worktree)
  • Real-binary smoke: --help, schema ark.vtxos.refresh, non-TTY refusal exits 2 with the INVALID_ARGS envelope before any RPC reaches the daemon, --yes proceeds straight to dispatch

fix #986

In this commit, we add a RefreshFeeEstimate message to
RefreshVTXOsResponse so a dry_run refresh can carry an advisory,
itemized operator-fee preview for the selected VTXOs. Today the
refresh path never surfaces the fee anywhere: the binding amount is
set by the server-issued JoinRoundQuote at seal time and
auto-accepted against the client's MaxOperatorFeeSat cap, so the
user only learns the charge afterwards via the fee history.

The per-outpoint rows carry the amount and remaining lifetime the
daemon resolved for each VTXO (removing the manual amount /
remaining-blocks input the standalone fees estimate command
requires) plus the operator's liquidity / on-chain share / margin
breakdown. Rows are deliberately NOT waiver-adjusted so their
components always sum to their total; the selection-level
free_refresh_eligible flag and zeroed total express the
free-late-refresh waiver instead, mirroring the operator's
all-or-nothing seal-time rule from lumos#675. estimate_error keeps
dry_run usable as a validity probe when the operator or chain
height is unavailable: the preview still returns and the caller is
told the numbers are absent rather than zero.

The daemon-side population of the new field lands in the next
commit. Part of #986.
@levmi levmi added cli CLI and user-facing commands Fees ux Usability, error messages, first-run labels Jul 17, 2026
@levmi
levmi requested review from Roasbeef and sputn1ck July 17, 2026 16:13

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a fee-consent gate for the ark vtxos refresh command and its corresponding MCP tool, requiring explicit confirmation or the --yes flag on non-interactive stdin to proceed with a fee-charging refresh. It implements a dry-run preview mechanism on the daemon side that calculates advisory fee estimates, handles operator quote deduplication, and computes free-refresh waivers. A review comment identifies a potential integer overflow vulnerability when accumulating the total fee estimate in estimateRefreshFees and suggests a safe addition check to prevent it.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +336 to +345
total += quote.TotalFeeSat
if total > int64(btcutil.MaxSatoshi) {
r.server.log.WarnS(ctx, "Refresh fee estimate: "+
"selection total overflows",
fmt.Errorf("total=%d", total))

return degradeRefreshEstimate(
est, "operator fee estimate invalid",
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In estimateRefreshFees, total is accumulated by adding quote.TotalFeeSat at each iteration. Since total is an int64, if the sum of fees exceeds MaxInt64, it will overflow and wrap around to a negative value. This would bypass the check total > int64(btcutil.MaxSatoshi) and result in a negative or incorrect total fee estimate being returned to the client.\n\nTo prevent integer overflow, perform a safe addition check before adding quote.TotalFeeSat to total.

\t\tif total > int64(btcutil.MaxSatoshi)-quote.TotalFeeSat {\n\t\t\tr.server.log.WarnS(ctx, \"Refresh fee estimate: \"+\n\t\t\t\t\"selection total overflows\",\n\t\t\t\tfmt.Errorf(\"total=%d\", total+quote.TotalFeeSat))\n\n\t\t\treturn degradeRefreshEstimate(\n\t\t\t\test, \"operator fee estimate invalid\",\n\t\t\t)\n\t\t}\n\t\ttotal += quote.TotalFeeSat

levmi added 6 commits July 17, 2026 11:33
In this commit, we populate the new RefreshVTXOsResponse fee
estimate on the dry-run path. The daemon resolves every selected
VTXO to its full descriptor, computes each remaining lifetime from
the chain tip, and fetches the operator's itemized quote through
the existing EstimateFee proxy, deduped on (amount, remaining
blocks). The remaining-blocks figure is clamped to 1 for expiring
VTXOs because the operator treats zero as "price the full
sweep-delay lifetime".

The explicit-outpoint path now looks targets up in the VTXO store
instead of only parsing strings: an unknown or non-live outpoint
surfaces as InvalidArgument instead of echoing back as a plausible
preview, mirroring the LiveState filter the --all path already
applies — the real refresh can never execute either, and dry_run
is the validity probe the CLI consent prompt trusts. Explicit
selections are also deduped (order-preserving) so a repeated
outpoint can neither double-count in the estimate nor register a
doomed duplicate forfeit pair with the wallet.

The free-late-refresh waiver (lumos#675) is applied locally: the
operator's EstimateFee prices every refresh as paid, so quoting a
free late refresh through it alone would over-quote. The daemon
already caches the advertised window in its operator terms and
knows a refresh selection is the pure one-for-one renewal shape the
waiver requires, so a selection fully inside the window previews
with an explicit zero total while the rows keep the ordinary paid
quote to show what the waiver saves.

The estimate is strictly best-effort and degrades all-or-nothing:
every quote is fetched and vetted (no negative or beyond-money-
supply values, no overflowing selection total) before any component
is written to a row, an unreachable operator or chain backend sets
estimate_error while the preview and the locally computed waiver
verdict survive, and the total uses explicit proto presence — it is
only set when meaningful, so a degraded estimate can never be
misread as a free refresh.

The dry-run branch also moves ahead of the wallet-ready gate,
matching the LeaveVTXOs ordering rule that pure-argument validation
and previews must not depend on wallet state (the H-5 fix's comment
already claimed this parity). A side effect is that an empty
selection=all dry run now honestly reports status "preview" instead
of "queued". The real refresh path still gates on wallet readiness
before queuing.

Part of #986.
In this commit, we render the dry-run fee estimate on the refresh
command. The itemized numbers stay in the JSON body on stdout;
stderr gains a short human-readable summary so an operator reading
the terminal sees the headline cost — or the free-refresh-window
verdict, or the degraded-mode warning — without stdout consumers
having to strip prose. Every wording branch repeats that the value
is advisory and the binding fee is set at seal time, and the
degraded branch explicitly says a fee still applies so a missing
estimate is never read as a free refresh. VTXOs below the
operator's minimum viable amount add a count-level warning,
mirroring the fees estimate command's below-dust handling.

A non-empty dry-run preview that carries no estimate at all can
only come from a daemon predating the feature, so the CLI warns
about that skew explicitly instead of silently dropping the
preview the flag help promises.

The fees estimate help now points refresh users at `ark vtxos
refresh --dry_run`, which resolves each selected VTXO's amount and
remaining lifetime automatically instead of requiring both by hand.

Part of #986.
In this commit, we stop dispatching a real refresh without consent.
Before this change the number a dry run can now surface was not
actionable: by the time a user read anything, the refresh was
queued, auto-joined, and priced by the auto-accepted seal-time
quote, with no cancel surface anywhere in between. The refresh
command now mirrors the leave --all consent pattern: --yes skips
the gate for scripted use, non-interactive stdin refuses to prompt
and directs the caller to --yes or --dry_run (so agents are never
blocked, per the issue's acceptance criteria), and only an
interactive TTY prompts.

The interactive prompt shows the advisory estimate first — fetched
through the same RPC in dry-run form — so the operator consents to
a number rather than a mystery fee, and the prompt goes to stderr:
stdout stays reserved for the JSON body, so a piped invocation can
never swallow the question and read as a hung command. A preview
rejected as InvalidArgument aborts outright (the real dispatch
would reject the same request shape); any other preview failure
degrades to prompting with an explicit "still charged the
seal-time fee" warning, so a broken estimate path never makes
refreshes unconfirmable and a missing estimate is never read as
free. An empty selection (refresh --all with no live VTXOs) skips
the prompt entirely — there is no fee to consent to, and warning
about a charge for a no-op would be false.

The schema registry entry gains the yes parameter and now names the
fee in its description, so schema consumers see the same contract
as the flag surface. getDaemonClient becomes a package-level
indirection (mirroring stdinIsTTY) so wiring tests can drive the
full command path against an in-process bufconn daemon and pin
that the gate runs before any dispatch: a refused invocation must
reach the daemon zero times.

Part of #986.
In this commit, we move the leave --all confirmation prompt from
stdout to stderr, matching the refresh confirmation gate and the
package's stream-split invariant: stdout is reserved for the JSON
body, diagnostics go to stderr. Before this change, piping the
command (e.g. into jq) on an interactive terminal swallowed the
prompt into the pipe — the command read as hung, and a user who
typed y blind fed prose into the JSON consumer.

Part of #986.
In this commit, we extend the refresh fee-consent contract to the
MCP surface. The MCP tool calls the daemon directly rather than
executing the cobra command, so the CLI gate never ran there: an
agent could queue a fee-incurring refresh with no warning, while
the schema registry — documented as the shared source of truth for
CLI commands and MCP tools — promised that a real refresh requires
yes. The tool description also never mentioned the fee.

The ark.vtxos.refresh tool now takes a yes acknowledgement: a real
(non-dry-run) call without it returns an immediate, actionable
error naming both the dry_run:true preview and the yes:true
acknowledgement path — nothing can block on MCP, matching the
CLI's non-interactive refusal — and the description now names the
seal-time fee and the preview-first flow. The dry-run preview
carries the same itemized fee estimate as every other surface.

Part of #986.
In this commit, we document the refresh-fee visibility surface. The
CLI guide's refresh section now names the operator fee and the
seal-time quote that binds it, describes the dry-run estimate
(per-outpoint amounts and lifetimes resolved by the daemon, the
free-refresh-window verdict, the degraded estimate_error mode), and
covers the interactive confirmation plus the non-interactive --yes
requirement, with examples updated to match.

The waveclicommands agent docs gain the refresh consent-gate
invariant — CLI prompt, non-interactive refusal, the MCP tool's yes
argument, and the absent-not-zero wire semantics of a degraded
total — alongside the existing leave --all and recovery escalate
prompt-refusal postures. The waved agent docs gain the dry-run
estimation invariant (pre-wallet-gate ordering, store-resolved
live-only outpoints, quote dedupe and validation, locally computed
waiver, degrade-only failure mode). The repo's own agent-facing
refresh examples in the waved skill and CONTEXT.md pick up
--dry_run/--yes so an agent following them is not stopped cold by
the new non-interactive refusal. Both members of each touched
CLAUDE/AGENTS pair receive the same edit; the waved pair's
pre-existing divergence on unrelated OOR text is left to the
doc-gardening workflow.

Part of #986.
@levmi
levmi force-pushed the levmi/refresh-fee-preview branch from 64279c1 to 021094c Compare July 17, 2026 16:34
@levmi
levmi requested review from bhandras and removed request for Roasbeef July 17, 2026 17:51
@levmi

levmi commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review this PR

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @levmi's task in 2m 51s —— View job


Review: Preview refresh fees and gate refresh on consent

I read the daemon estimate path (waved/rpc_refresh_estimate.go, rpc_fees.go, the RefreshVTXOs refactor in rpc_server.go), the CLI/MCP consent gates (cmd_vtxos.go, cmd_mcp.go), the proto, and the tests. This is a well-constructed, carefully-commented change. The all-or-nothing degrade, the "absent, never zero" total via explicit proto presence, the dedup-before-canonicalize, and the dry-run-before-wallet-gate reordering are all sound and match the stated intent. Coverage is genuinely thorough (20+ estimate cases including partial failure, nonsense quotes, expired clamping, window boundary, duplicate collapse).

A few notes below, one of which corrects the Gemini finding.


1. The Gemini overflow finding is a false positive

Gemini flagged the total += quote.TotalFeeSat accumulation in estimateRefreshFees (waved/rpc_refresh_estimate.go:336) as an int64 overflow risk. It isn't reachable:

var total int64
for _, row := range est.Outpoints {
        ...
        total += quote.TotalFeeSat            // each quote ≤ MaxSatoshi (refreshQuoteSane)
        if total > int64(btcutil.MaxSatoshi) { // returns/degrades the moment it exceeds
                return degradeRefreshEstimate(est, "operator fee estimate invalid")
        }
}

The loop maintains the invariant total ≤ MaxSatoshi at the top of every iteration, because it returns as soon as total crosses MaxSatoshi. Since each vetted quote.TotalFeeSat is also ≤ MaxSatoshi, the post-add value is bounded by 2 × MaxSatoshi ≈ 4.2e15, which is nowhere near MaxInt64 ≈ 9.2e18. Wrapping to a negative value is impossible, so the check can't be bypassed. Gemini's suggested pre-add form is equivalent and harmless, but not needed to fix a real bug. No change required here.

2. refresh --all + --outpoint silently drops the outpoints (minor UX)

In vtxosRefresh (cmd/wavecli/waveclicommands/cmd_vtxos.go:202-218):

if !all && len(outpoints) == 0 {
        return fmt.Errorf("either --outpoint or --all is required")
}
if all {
        req.Selection = &waverpc.RefreshVTXOsRequest_All{All: true}
} else {
        // outpoints
}

If a user passes both --all and --outpoint, --all wins and the outpoints are silently ignored. buildLeaveVTXOsRequest (same file, line 669) explicitly rejects this combination (--outpoint and --all are mutually exclusive). Since the PR is explicitly aligning refresh with the leave pattern, mirroring that guard would remove a surprising papercut. Not a correctness bug (the proto oneof can't hold both), just an inconsistency. Fix this →

3. Per-outpoint components are trusted to sum to total_fee_sat but never verified

OutpointFeeEstimate's proto doc promises "its components always sum to the row's total_fee_sat", and summarizeRefreshFeeEstimate / clients may rely on that. refreshQuoteSane (rpc_refresh_estimate.go:182) validates each component ≥ 0 and total ≤ MaxSatoshi, but not that liquidity + onchain_share + margin == total. A broken/hostile operator could return components that don't reconcile with the total, and the daemon would echo the inconsistency into the advisory rows. Low impact (advisory only, and the selection total uses TotalFeeSat directly, not the summed components), but since the code already hardens against operator nonsense, adding the sum check would make the invariant the proto claims actually hold. Optional.

4. Serial operator round-trips in the estimate loop (perf, acceptable)

estimateRefreshFees fetches operator quotes serially inside the loop (deduped by (amount, remaining_blocks)). For a large wallet with many distinct amounts, a --dry_run --all preview could issue many sequential EstimateFee round-trips within one RPC handler. The dedup keeps the common case (VTXOs sharing a round/expiry) cheap, and this is a preview path bounded by the caller's context, so it's fine as-is — just worth being aware of if diverse-amount wallets show up slow. No action needed.


Things I specifically checked and liked

  • Version skew both directions: new CLI vs old daemon warns (cmd_vtxos.go:262), old client ignores new fields. Correct.
  • "Absent, never zero": degradeRefreshEstimate only stamps a zero total when the locally-computed waiver is eligible; otherwise the optional int64 stays unset. This is the right way to stop an agent misreading a degraded estimate as free.
  • Dry-run reorder: moving the preview ahead of requireWalletReady() matches the documented LeaveVTXOs H-5 ordering, and TestRefreshVTXOsRealPathStillRequiresWallet pins that the real path still gates on wallet readiness.
  • MCP parity: checkMCPRefreshConsent returns an immediate actionable error (no prompt to hang on), and the tool description names the fee + preview-first flow.
  • Non-live/unknown explicit outpoints now InvalidArgument instead of echoing back as a plausible preview — this is the honest-validity-probe improvement, and the CLI's InvalidArgument-aborts-else-degrades split in confirmRefreshIfNeeded handles it correctly.

Overall: looks good to merge. Item #2 is the only one I'd suggest addressing before merge (cheap, improves consistency); #1 needs no action (Gemini false positive); #3/#4 are optional.
levmi/refresh-fee-preview

In this commit, we extend the refresh quote sanity check to enforce
the reconciliation the proto documents: OutpointFeeEstimate promises
its components always sum to the row's total_fee_sat, and the
operator's fee schedule defines the total as exactly liquidity +
on-chain share + margin, but refreshQuoteSane only vetted each field
for negative or beyond-money-supply values. A broken (or hostile)
operator could return an itemization that contradicts its own total
and the daemon would echo the inconsistency into the advisory rows.

Each component is now individually bounded by MaxSatoshi before the
wrap-free sum is compared against total_fee_sat, so a mismatched
quote degrades the estimate all-or-nothing exactly like a negative
one. Raised in the automated review of #987.

Part of #986.
levmi added a commit that referenced this pull request Jul 20, 2026
In this commit, we extract the refresh selection parsing into a
shared buildRefreshVTXOsRequest builder, mirroring the
buildLeaveVTXOsRequest pattern, and add the mutual-exclusion guard
the leave surface already has: passing both --outpoint and --all
previously let --all win silently, refreshing (and charging for)
every live VTXO while the named outpoints were dropped.

The MCP tool now goes through the same builder, closing the same
silent-drop gap on that surface and turning an empty selection into
an immediate client-side error instead of a daemon round trip.
Raised in the automated review of #987.

Part of #986.
@levmi

levmi commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review findings in two follow-up commits:

Per #1, the accumulation overflow flagged by Gemini is not reachable (the loop invariant keeps total ≤ MaxSatoshi at the top of each iteration and each vetted quote is ≤ MaxSatoshi), so no change there. #4 left as-is (dedup keeps the common case cheap).

In this commit, we extract the refresh selection parsing into a
shared buildRefreshVTXOsRequest builder, mirroring the
buildLeaveVTXOsRequest pattern, and add the mutual-exclusion guard
the leave surface already has: passing both --outpoint and --all
previously let --all win silently, refreshing (and charging for)
every live VTXO while the named outpoints were dropped.

The MCP tool now goes through the same builder, closing the same
silent-drop gap on that surface and turning an empty selection into
an immediate client-side error instead of a daemon round trip.
Raised in the automated review of #987.

Part of #986.
@levmi
levmi force-pushed the levmi/refresh-fee-preview branch from b2c7902 to 5551d2f Compare July 20, 2026 18:41
@Roasbeef Roasbeef added the backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch label Jul 20, 2026

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🎳

@Roasbeef
Roasbeef merged commit 07016dd into main Jul 20, 2026
20 checks passed
@github-actions

Copy link
Copy Markdown

Successfully created backport PR for v0.1.x-branch:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch cli CLI and user-facing commands Fees ux Usability, error messages, first-run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wavecli: ark vtxos refresh gives no fee visibility or warning before charging

2 participants