This note sketches a small, opt-in path for moving client-side swap
execution into waved without making every default daemon or CLI build
carry the swap runtime yet.
The swap SDK already has durable pay and receive FSMs under sdk/swaps,
but production swap execution needs a single owner. A short-lived CLI
process should not be the thing driving progress. If the CLI exits, swap
progress should continue in the daemon instead of waiting for another
manual command.
That is fine for manual regtest work, but it is not the model we want for WaveWalletDK or application integrations. A user should submit a swap intent, receive a stable payment hash or invoice, and then let the long-lived client runtime complete, refund, expire, or mark the swap as requiring intervention.
Add a daemon-owned swap executor behind a build tag, tentatively:
swapruntime
Default builds remain unchanged from a dependency and runtime perspective:
make buildSwap-enabled builds opt in explicitly:
make build-swapruntime
make unit-swapruntimeThe tag gates both sides that would otherwise embed swap dependencies:
wavedstarts the background swap executor only inswapruntimebuilds.wavecliexposes daemon-backedswapcommands only inswapruntimebuilds.- the CLI talks to a daemon-hosted swap subserver; it does not import
sdk/swaps, open the swap DB, or dialswapdk-serverdirectly.
This keeps the feature easy to review and test while avoiding a broad default-build dependency expansion before the runtime contract is settled.
Use one tag for the first slice. Splitting daemon and CLI tags now would create unsupported combinations without much benefit.
Suggested file layout:
rpc/swapclientrpc/swap_client.proto
swapclientserver/*.go //go:build swapruntime
cmd/waved/swapruntime.go //go:build swapruntime
cmd/waved/swapruntime_stub.go //go:build !swapruntime
cmd/wavecli/waveclicommands/cmd_swap_rpc.go //go:build swapruntime
cmd/wavecli/waveclicommands/cmd_swap_stub.go //go:build !swapruntime
The stub path should be boring:
wavedcompiles with no swap executor and no swap server dialer.waveddoes not register the swap subserver in non-swapruntimebuilds.wavecliexposes a tiny command that returnsswap support requires building with tags="swapruntime".
A stub command keeps swap support discoverable in non-swapruntime builds without linking a second CLI-side swap runtime into ordinary binaries.
The tagged CLI command should depend on the generated swap subserver client,
not sdk/swaps. It should be structurally similar to cmd_getinfo.go: parse
flags, build a protobuf request, call the daemon-hosted service, and render the
protobuf response. Any richer Go models can live in sdk/ark or a future
WaveWalletDK-facing package, but the CLI should not become a second swap runtime.
The daemon core should also avoid importing sdk/swaps directly. Today
sdk/swaps -> sdk/ark -> waved, so a direct import from waved creates an
import cycle. Register the optional subserver through a programmatic daemon RPC
registrar and place the swap runtime in a package outside waved.
Generated protobuf code is harder to build-tag cleanly. Prefer keeping the
swap subserver protobuf and generated client/server stubs compiled in all
builds, while making service registration build-tagged. That avoids
generated-code split-brain while still keeping the actual swap executor,
subserver registration, and sdk/swaps dependency out of default daemon code.
The executor is a daemon subsystem that wraps sdk/swaps; it should not
reimplement pay or receive state transitions.
wavecli / SDK caller
|
v
swapclientrpc.SwapClientService
|
v
waved swap executor // build tag: swapruntime
|
v
sdk/swaps
|
v
sdk/ark -> waverpc local client -> waved Ark runtime
swaprpc -> swapdk-server
The executor owns:
- swap store lifecycle (
~/.waved/data/<network>/swaps.dbby default, configurable); - swap-server transport config;
- construction of
sdk/swaps.SwapClient; - a per-payment-hash worker registry;
- startup resume for non-terminal sessions;
- status snapshots and progress notification.
It does not own Ark wallet logic, OOR construction, vHTLC policy logic, or
swap FSM transitions. Those remain in sdk/ark, oor, policy packages, and
sdk/swaps.
The swap RPC interface is the control-plane contract for swaps. It should be a
daemon-hosted subserver registered on the same gRPC listener as
waverpc.DaemonService, not new methods bolted onto DaemonService itself.
The CLI, WaveWalletDK, and any other external process should all use this subserver.
The executor is an internal implementation detail behind it.
The split keeps DaemonService focused on the core Ark wallet/runtime API and
lets swap support remain optional without making the main daemon RPC interface
look like every build supports swaps. It also gives WaveWalletDK a clean service
name and package to bind to later.
The first API can stay small:
StartPay(invoice, max_fee_sat, idempotency_key?) -> payment_hashStartReceive(amount_sat, idempotency_key?) -> payment_hash, invoiceListSwaps(pending_only) -> []SwapSummaryGetSwap(payment_hash) -> SwapSummarySubscribeSwaps(payment_hash?) -> stream SwapUpdateif cheap, otherwise defer and let callers pollGetSwap.
Suggested proto shape:
syntax = "proto3";
package swapclientrpc;
service SwapClientService {
rpc StartPay(StartPayRequest) returns (StartPayResponse);
rpc StartReceive(StartReceiveRequest) returns (StartReceiveResponse);
rpc ListSwaps(ListSwapsRequest) returns (ListSwapsResponse);
rpc GetSwap(GetSwapRequest) returns (GetSwapResponse);
rpc SubscribeSwaps(SubscribeSwapsRequest)
returns (stream SubscribeSwapsResponse);
}The request/response types should be daemon-owned protobufs, not imported
swaprpc server messages. The daemon may translate internally to sdk/swaps,
but callers should see one stable client-daemon contract.
Possible location:
rpc/swapclientrpc/swap_client.proto
Putting the proto under rpc/swapclientrpc gives it a real Go package named
swapclientrpc while keeping waverpc.DaemonService unchanged. The important
boundary is separate service, same daemon listener.
Minimal message shape:
StartPayRequest: BOLT11 invoice, max routing fee, optional caller idempotency key, optional wait policy only if we want server-side wait later.StartPayResponse: payment hash and initialSwapSummary.StartReceiveRequest: amount, optional caller idempotency key.StartReceiveResponse: payment hash, BOLT11 invoice, and initialSwapSummary.ListSwapsRequest: pending-only flag, optional direction filter.ListSwapsResponse: repeatedSwapSummary.GetSwapRequest: payment hash.GetSwapResponse: oneSwapSummary.SubscribeSwapsRequest: optional payment hash and optional direction filter.SubscribeSwapsResponse:SwapSummaryplus update reason if useful.
StartReceive should return after the invoice and receive session are
durably persisted. The daemon worker then waits for the swap server to fund
the vHTLC and claims in the background.
StartPay should return after the pay session has a durable payment hash
and enough state to resume. The worker then drives funding, claim observation,
timeout refund, and terminalization in the background.
The public status model should mirror sdk/swaps.SwapSummary rather than
inventing a second vocabulary. It must preserve:
- direction: pay or receive;
- payment hash;
- current state;
- amount, fee, max fee;
- deadline / expiry;
- funding, claim, and refund OOR session IDs when known;
- intervention / failure reason when terminal and non-happy.
Represent directions and states as daemon protobuf enums. The RPC layer should translate the SDK's durable FSM state strings at the boundary so callers get a stable control-plane vocabulary while the swap store can continue to persist SDK-native state names. Later enum changes must reserve removed field numbers, following the rest of the daemon RPC compatibility rules.
The non-swapruntime daemon should not register the swap subserver at all.
Clients that call the service on a default daemon will receive the normal gRPC
unknown-service / unimplemented response. Tagged CLI builds should map that
case to a clear user-facing message such as "daemon was built without
swapruntime support".
The repo also provides convenience targets for the tagged path:
make build-swapruntime
make build-swapclient # alias
make install-swapruntime
make unit-swapruntimeThese targets are thin wrappers around tags="swapruntime" (unit-swapruntime
also adds wavewalletrpc) so local developer and integration-test environments
do not need to remember the exact build tag.
Implementation registration should look like the existing DaemonService
registration path in waved/server.go, but through a programmatic registrar
so the tag split stays contained and waved does not import sdk/swaps:
waverpc.RegisterDaemonServiceServer(s.grpcServer, s.rpcServer)
for _, registrar := range s.cfg.RPCServiceRegistrars {
cleanup, err := registrar(ctx, s.grpcServer, s.rpcServer, s.cfg)
// ...
}Only the tagged cmd/waved file appends the swapclientserver.Register
registrar. Non-tag builds do not import the swap runtime package from daemon
startup code and do not advertise swap support on the gRPC server.
Inside that registrar, the swap executor should consume sdk/ark through a
private in-process bufconn transport over the daemon's existing
DaemonService implementation. That keeps the future deployment shape honest:
the swap client can later be split into a separate process that dials Ark over
network gRPC, while the single-binary daemon path uses the same generated RPC
contract without dialing its own public listener. The swap subserver should not
implement a partial local DaemonServiceClient for the handful of Ark methods
the swap FSM happens to call today.
This work intentionally does not duplicate the receive-event or server protocol changes in:
lightninglabs/wavelength#337, which owns the SDK receive-side mailbox HTLC event flow, onion validation,RequestChannelIDsignature change, receive auth key plumbing, and swap-store schema changes.lightninglabs/swapdk-server#24, which owns the swapd-hosted mailbox edge, payment-hash-bound virtual route registration, and server publication of funded out-swap HTLC notifications.
The daemon-owned executor should treat those as lower-layer dependencies. This PR should remain a control-plane/background-execution layer:
- no changes to
swaprpc/swap.proto; - no custom mailbox or onion validation implementation;
- no server-side route binding changes;
- no WaveWalletDK API work.
With wavelength#337 merged, swapclientserver should treat receive auth
as a lower-layer daemon capability instead of owning a separate key file. The
daemon-side swap client wraps the existing waved RPC implementation with
sdk/ark over bufconn, passes that Ark facade to sdk/swaps as its
DaemonConn, and lets sdk/swaps request payment-scoped receive-auth signing
and ECDH through the daemon-backed ReceiveAuthKey flow. This keeps the
background executor focused on control-plane work while restart continuity for
receive auth remains anchored in the daemon wallet API introduced by #337.
The executor should maintain a map[lntypes.Hash]*worker protected by a
mutex. Starting or resuming a swap first claims the worker slot. A duplicate
start/resume for an active payment hash returns the existing status or wakes
the existing worker; it must not create a second driver.
Startup flow:
- open the swap store;
- construct the swap client;
- list pending pay and receive sessions;
- register one worker per payment hash;
- call the existing
ResumePayViaLightningorResumeReceiveViaLightningpath inside each worker; - publish a status update when the worker exits.
Manual resume becomes a wake-up signal, not a separate execution path. This is the Loop-like behavior we want: the daemon owns progress, while CLI and SDK callers control or observe it.
Superseded (#907). Every reference to a user-facing
wavecli swapverb in this document — here and in the Testing Plan, Rollout, and Planned Commit Stack sections below — was retired:send/recv --offchainandactivity(plusactivity inspect <id>) cover them. Theswapruntimedaemon runtime this document describes is unchanged and still powers those verbs; only the curated CLI/MCPswapsurface was removed. The generateddev swapclientsurface remains for low-level access. These sections are retained as design background.
With swapruntime, wavecli swap should become a daemon-control surface.
The CLI should not open the swap DB, construct sdk/swaps.SwapClient, dial
swapdk-server, or drive the FSM directly. All CLI operations go through
swapclientrpc.SwapClientServiceClient.
Recommended commands:
swap pay --invoice ... [--maxfee ...]swap receive --amount ...swap list [--pending]swap show [payment_hash]swap watch [--pending] [--include-existing]
Exiting the CLI must not stop the swap; pay and receive return as soon as
the daemon has durably persisted the session.
SubscribeSwaps landed in the first slice, so swap watch streams live
updates directly instead of falling back to polling GetSwap.
Swap storage and swap-server settings belong in waved config. That keeps
daemon startup as the single place where background worker dependencies are
configured, and keeps the CLI focused on the daemon RPC contract.
The tagged daemon config should include:
- implicit enable when the tag is built and config is set, rather than a
separate
swap.enablefield; swap.serveraddress;swap.servertlscertpath;swap.serverinsecurefor regtest/dev;swap.databasefilename;- receive auth key policy.
Receive auth material is the one real design question. The current CLI invoice generator is process-local, which is not suitable once the daemon owns receive swaps across restart. The executor needs a stable receive key source:
- best long-term: derive from the wallet/keychain so backup and unlock policy are shared with the Ark runtime;
- acceptable first slice: daemon-scoped encrypted local key, only if we are explicit that it is temporary;
- avoid: unprotected standalone key file.
The mailbox-onion receive work should be the baseline for this path: the daemon receives durable HTLC events, validates the forwarded onion locally, persists accepted event metadata, and only then claims the vHTLC.
The executor should expose optional caller idempotency keys at the daemon API. Internally:
- pay funding OOR sends use stable keys derived from the payment hash;
- receive claim OOR sends use stable keys derived from the payment hash and claim role;
- pay refund OOR sends use stable keys derived from the payment hash and refund role.
This keeps retries and daemon restarts from creating duplicate OOR sends even when coin selection would otherwise choose different inputs.
Empty caller keys can be allowed for CLI parity, but WaveWalletDK should pass stable keys once its surface exists.
Unit tests under swapruntime:
- executor startup resumes pending pay and receive rows;
- duplicate active payment hash does not start a second worker;
- terminal rows are skipped on startup;
- swap subserver methods translate requests into executor calls and summaries back into protobufs;
- tagged CLI commands call a fake
SwapClientServiceClientand never constructsdk/swaps.SwapClient; - non-tag builds compile without registering the swap subserver and without
importing
sdk/swapsfromwavedorwavecli.
Integration tests under swapruntime:
Superseded (#907): the
wavecli swapCLI verbs below were removed; the daemon runtime they exercised is unchanged. See the note under "CLI Behavior".
swap payreturns, CLI exits, daemon completes the swap;swap receivereturns invoice, CLI exits, daemon claims after payment;- daemon restart with pending pay resumes without duplicate OOR funding;
- daemon restart with pending receive resumes from mailbox/funding metadata;
- timeout/refund path completes without manual
swap resume; swap list/show/watchreport terminal failure/intervention context.
Build checks:
go test ./cmd/wavecli/waveclicommands ./cmd/waved ./waved ./sdk/swaps
go test -tags=swapruntime ./cmd/wavecli/waveclicommands ./cmd/waved ./waved ./sdk/swaps ./swapclientserver- Land the build-tag scaffolding and no-op default stubs.
- Add separate swap subserver protobuf messages and generated stubs.
- Add daemon config and executor startup behind
swapruntime. - Wire tagged subserver handlers to the executor.
- Convert CLI swap commands to daemon-control mode behind
swapruntime. (Superseded — #907 removed thewavecli swapCLI surface entirely.) - Add restart and timeout integration coverage through the CLI/RPC surface.
- Document the WaveWalletDK handoff surface once the daemon API stops moving.
This lets us keep the default binaries conservative while giving swap-enabled builds the runtime model we actually want.
The implementation should land as a small stack where each commit either compiles on its own or is a generated-code companion to the previous commit.
-
swapclientrpc: add optional swap subserver protoAdd a separate swap client proto and generated gRPC bindings. The service should not be added to
DaemonService; it should generate its ownSwapClientServiceClientandSwapClientServiceServer. Message types include start pay, start receive, list, get, subscribe,SwapSummary, and a direction enum. State remains a string in this slice so it can mirror the SDK without inventing a second enum too early. Nowavedregistration yet. -
waved: add swapruntime registration seamAdd the build-tagged registration hook. Default builds register only the existing daemon service.
swapruntimebuilds registerSwapClientServiceon the same gRPC server. This commit should prove the optional-subserver mechanics without implementing swap execution. If a default-build CLI stub is kept, it also lands here. -
waved: add tagged swap executor skeletonAdd
swapruntimeconfig, store opening/closing, swap-server dial config, executor lifecycle, and startup/shutdown integration. The executor can initially list pending sessions and maintain the per-payment-hash worker registry, but it does not need to drive all swap paths yet. Default builds must not importsdk/swaps. -
waved: resume pending swaps in backgroundWire the executor to
sdk/swaps.ResumePayViaLightningandResumeReceiveViaLightning, register one worker per pending session on startup, skip terminal rows, and make manual resume a worker wake-up rather than a second driver. Add focused unit tests around dedupe and terminal skip behavior. -
waved: implement swap subserver start and status RPCsImplement
StartPay,StartReceive,ListSwaps, andGetSwapagainst the executor. Add conversion helpers betweensdk/swapssummaries and protobuf summaries. Start calls return after the initial session is durably persisted and worker ownership is established. -
waved: stream swap updatesImplement
SubscribeSwapsif the update path is simple enough. If it is not, leave the RPC unimplemented in tagged builds and document CLI polling as the first slice. This commit should not block the start/list/get control plane. -
wavecli: move swap commands to daemon subserver(Superseded — #907 removed thewavecli swapCLI surface instead.)Under
swapruntime, convertswap pay,swap receive,swap list,swap show, andswap watchto callSwapClientServiceClient. Remove direct CLI construction ofsdk/swaps.SwapClient, swap DB opening, and directswapdk-serverdialing from the intended tagged path. -
itest: cover daemon-owned swap executionAdd integration coverage through the CLI/RPC surface: pay returns while the daemon completes, receive returns an invoice while the daemon later claims, daemon restart resumes pending pay/receive, and timeout/refund paths finish without manual
swap resume. -
docs: capture WaveWalletDK swap handoffOnce the RPC shape has survived the first implementation pass, document the stable WaveWalletDK handoff: service name, start/list/get/subscribe semantics, state meanings, idempotency expectations, and terminal failure fields.