Skip to content

multi: add a REST transport option for the lnd wallet backend - #965

Open
ellemouton wants to merge 3 commits into
mainfrom
ellemouton/lnd-rest-wallet-backend
Open

multi: add a REST transport option for the lnd wallet backend#965
ellemouton wants to merge 3 commits into
mainfrom
ellemouton/lnd-rest-wallet-backend

Conversation

@ellemouton

Copy link
Copy Markdown
Member

What

Add a REST transport option for the lnd wallet backend, plus a CI
configuration that exercises it. Today waved's lnd backend connects over
gRPC only (via lndclient, which has no REST mode). Set lnd.transport=rest
to talk to lnd over its grpc-gateway REST interface instead.

Why

The lnd backend was gRPC-only, and a reviewer asked whether it could also run
over REST. lnd already exposes every subserver method the backend uses over
its grpc-gateway REST interface — signer (including MuSig2), walletkit,
chainkit, and the streaming chain-notifier — so a REST backend is a matter of
adapting the existing rpc/restclient plumbing to the lndclient interfaces
rather than a fork away from it.

What this PR does

lndrest (new package): REST implementations of the lndclient
SignerClient, WalletKitClient, ChainKitClient, ChainNotifierClient
(server-streaming), and the slice of LightningClient the daemon actually
uses (GetInfo / WalletBalance), built on the existing rpc/restclient
HTTP + server-stream helpers. Methods the daemon never calls in lnd mode
return errUnsupportedOverREST.

waved: s.lnd is abstracted behind an lndServices seam so either a
gRPC (*lndclient.GrpcLndServices) or REST (*lndrest.Backend) backend drops
in; connectLnd branches on lnd.transport. New LndConfig.Transport
(default grpc) + lnd.transport flag + sample-waved.conf entry. Host is
reinterpreted per transport (host:port for gRPC, an HTTPS base URL for
REST). transport=rest requires lnd.macaroonpath, since the per-network
default macaroon path cannot be resolved over REST.

lndbackend: the locator-aware SignOutputRaw is routed through a narrow
Signer interface so the gRPC path keeps its raw-client behavior and the REST
signer sets the key locator directly (no raw gRPC client).

Testing / CI

  • Unit: config validation + the REST adapters (unary and streaming) in
    lndrest.
  • systest: TestLNDRESTBackendListVTXO stands up a REST-backed daemon
    against the harness lnd REST gateway and lists a seeded VTXO (connect +
    chain backend, end to end).
  • New CI job systest-lnd-rest: reruns the signing-capable directed-send
    flows over REST via ARK_SYSTEST_LND_TRANSPORT=rest
    (case='Send|LNDREST|Stranded|VHTLCRecovery') — send / OOR / on-chain /
    refresh / leave / vHTLC-recovery — so the signer, walletkit, chainkit, and
    streaming chain-notifier are exercised over REST rather than just gRPC. All
    eight fixture-driven flows pass over REST locally, and the daemon log
    confirms it connects to lnd's REST gateway port rather than gRPC.

Notes / follow-ups

  • Unlike the gRPC path, connectLndREST does not block on chain-sync /
    wallet-unlock (connectLndGRPC sets BlockUntilChainSynced /
    BlockUntilUnlocked). It proved reliable across the REST systests, but
    matching the gRPC sync gate (poll GetInfo until synced_to_chain, bounded
    by a configurable timeout) would be the safer parity and is a natural
    follow-up.
  • lndrest's LightningClient embeds a nil lndclient.LightningClient, so an
    unexpected LightningClient call would nil-panic; those methods are
    unreachable in lnd mode today.
  • A real-daemon itest (in the operator repo) driving OOR/refresh over a
    REST-backed client would extend signing coverage against a real operator,
    once this lands and the submodule is bumped.

🤖 Generated with Claude Code

waved's LND wallet backend connected to lnd over gRPC only (via
lndclient, which has no REST mode). LND exposes every subserver method
the backend uses over its grpc-gateway REST interface -- signer
(including MuSig2), walletkit, chainkit, and the streaming
chain-notifier -- so add lnd.transport=rest to talk to lnd over REST
instead of gRPC.

- lndrest: REST implementations of the lndclient SignerClient,
  WalletKitClient, ChainKitClient, ChainNotifierClient (streaming), and
  the LightningClient slice (GetInfo/WalletBalance) the daemon uses,
  built on the existing rpc/restclient HTTP + server-stream plumbing.
  Methods the daemon never calls in lnd mode return
  errUnsupportedOverREST.
- waved: abstract s.lnd behind an lndServices interface so either a
  gRPC (*lndclient.GrpcLndServices) or REST (*lndrest.Backend) backend
  drops in; connectLnd branches on lnd.transport. New
  LndConfig.Transport (default grpc) + lnd.transport flag +
  sample-waved.conf; Host is reinterpreted per transport (host:port for
  gRPC, HTTPS base URL for REST). transport=rest requires
  lnd.macaroonpath, since the per-network default cannot be resolved
  over REST.
- lndbackend: route the locator-aware SignOutputRaw through a narrow
  Signer interface so the gRPC path keeps the raw-client behavior and
  the REST signer sets the key locator directly (no raw gRPC client).

Verified with unit tests for the config and the REST adapters (unary +
streaming) and a systest (TestLNDRESTBackendListVTXO) that runs a
REST-backed daemon against the harness lnd REST port end to end.
The lnd REST wallet backend added in the previous commit was only
covered by unit tests plus one systest that seeds and lists a VTXO
(connect + chain backend, no signing). Add a CI configuration that
reruns the signing-capable directed-send flows against the REST backend
so the signer, walletkit, chainkit, and streaming chain-notifier are all
exercised end to end over REST.

- systest: newDirectedSendFixture reads ARK_SYSTEST_LND_TRANSPORT and,
  when it is "rest", defaults every daemon it builds onto the lnd REST
  transport (a per-test config mutator still wins). Unset (or "grpc")
  keeps the historical gRPC path, so local `make systest` is unchanged.
- Makefile: the systest / systest-verbose targets gain a case=<regexp>
  filter (mirroring the unit target) so a run can be scoped to a subset
  of tests.
- ci: a new systest-lnd-rest job sets ARK_SYSTEST_LND_TRANSPORT=rest and
  runs the fixture-driven send / OOR / on-chain / refresh / leave /
  vHTLC-recovery flows (case='Send|LNDREST|Stranded|VHTLCRecovery'). A
  single db backend (sqlite) is used since the wallet transport is
  orthogonal to the DB; the remaining systests do not use the lnd
  backend, so rerunning them over REST would add nothing.

Verified locally: all eight fixture-driven flows pass over REST, and the
daemon log confirms it connects to lnd's REST gateway port rather than
the gRPC port.

@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 REST-based transport option for connecting to lnd, allowing the daemon to communicate via lnd's grpc-gateway instead of native gRPC. It includes a new lndrest package that implements the necessary service interfaces, updates the waved server to support this transport via a new lnd.transport configuration, and adds comprehensive system tests to verify the REST backend functionality. My feedback focuses on the use of context.TODO() in SignOutputRaw, suggesting that the input.Signer interface be updated to accept a context for better traceability and cancellation propagation.

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 +231 to +247
c.logger(context.TODO()).DebugS(
context.TODO(),
"Signing output raw via LND remote signer",
slog.Int("input_index", signDesc.InputIndex),
slog.String("sign_method", signDesc.SignMethod.String()),
)

lndDesc := inputDescToLndclient(signDesc)

// Collect prevouts for taproot sighash computation. When the
// caller provides a PrevOutputFetcher we extract every input's
// previous output; otherwise we fall back to the single output
// in the sign descriptor.
prevOuts := prevOutputsFromDesc(tx, signDesc)

sigs, err := c.signer.SignOutputRawWithLocator(
context.Background(), tx, []*lndclient.SignDescriptor{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The SignOutputRaw method currently uses context.TODO() for logging and context.Background() for the signing call. This is because the input.Signer interface it implements doesn't accept a context, which hinders traceability and proper cancellation propagation.

While changing the upstream input.Signer interface might be a larger effort, it would be the ideal solution. A possible change would be:

// In input.Signer
SignOutputRaw(ctx context.Context, tx *wire.MsgTx, signDesc *input.SignDescriptor) (input.Signature, error)

This would allow you to pass a proper context through the call chain, improving observability and ensuring that operations can be cancelled correctly. If changing the interface is out of scope, consider adding a comment explaining why context.TODO() is used here.

@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 4m 13s —— View job


Review: REST transport for the lnd wallet backend

Read the full diff (lndrest/*, the waved seam + config, the lndbackend signer refactor, tests, and CI). Overall this is a clean, well-documented adaptation of the existing rpc/restclient plumbing onto the lndclient interfaces. The lndServices seam is tidy, the errUnsupportedOverREST "fail loudly" pattern is the right call, stream teardown is correctly tied to the caller's ctx (the HTTP request is ctx-bound, so Recv unblocks and closes the body on cancel), and the compile-time interface assertions guard against drift. Comments are strong throughout.

A few things worth addressing before merge.


1. Network-name check will reject testnet3 (likely bug, not covered by CI)

lndrest/lndrest.go:204

if info.Network != "" && info.Network != cfg.ChainParams.Name {
    return nil, fmt.Errorf("lndrest: lnd network %q does not match ... %q", ...)
}

cfg.ChainParams for the testnet config comes from networkToChainParamschaincfg.TestNet3Params, whose .Name is "testnet3". lnd's GetInfo normalizes its network string (lncfg.NormalizeNetwork) and reports "testnet" for testnet3. So on testnet3 this compares "testnet" != "testnet3"connect fails.

Because the systests run on regtest ("regtest" matches on both sides), this path is never exercised by CI — mainnet/regtest/signet happen to match, testnet3 does not (and testnet4 should be double-checked too). Consider normalizing before comparing, or mapping known aliases, rather than a raw string equality against chaincfg.Params.Name. Fix this →

2. REST connect does not gate on chain-sync / wallet-unlock

connectLndGRPC sets BlockUntilChainSynced / BlockUntilUnlocked; connectLndREST (waved/server.go:2653) only does a single GetInfo. You already flag this as a follow-up in the PR description — agreed it's the right follow-up. The Info returned by REST GetInfo already carries SyncedToChain, so a bounded poll on that field would be a small, self-contained parity fix. Worth at least a WarnS at connect time if !info.SyncedToChain so operators aren't left guessing.

3. NextAddr decodes with nil chain params

lndrest/walletkit.go:225

return btcaddr.DecodeAddress(resp.Addr, nil)

ImportTaprootScript two methods down uses m.conn.params. For bech32/bech32m addresses the HRP is self-describing so nil is functionally fine, but the inconsistency invites a subtle network-mismatch hole. Suggest passing m.conn.params here too for consistency and defense-in-depth.

4. lnd.macaroonpath requirement is enforced late

lndrest.New requires MacaroonPath and errors if empty — good — but Config.Validate() (waved/config.go:1301) doesn't check it for transport=rest. The failure therefore surfaces at connect time rather than config-validation time. Minor, but hoisting it into Validate() (only when Lnd.Transport == rest) gives a faster, clearer error.


Minor / nits

  • lightningClient embeds a nil lndclient.LightningClient (lndrest/lightning.go:30) — an unexpected non-overridden call nil-panics rather than returning errUnsupportedOverREST like every other sub-client. You call this out in the PR body; the ~45-method surface makes it a reasonable trade-off, but it's the one place the "fail loudly with a clear error" invariant doesn't hold. A tiny embedded shim returning errUnsupportedOverREST would close the gap if it ever becomes reachable.
  • gemini's context.TODO() note (lndbackend/client_wallet.go:231): this is pre-existing — ClientWallet uses background/TODO contexts throughout because input.Signer carries no ctx, and the type doc already explains it. Not introduced by this PR and not a blocker; changing input.Signer upstream is out of scope here.
  • marshalTxOut leaves nil entries for nil prevouts, same as the gRPC grpcLocatorSigner path — consistent, so fine, just noting the parity is intentional.

I didn't run the build/tests (this is a review-only pass); the logic above is from reading the diff. Item #1 is the one I'd most want confirmed (a quick testnet3 connect, or a unit assertion on the normalized names) before relying on non-regtest networks.
· ellemouton/lnd-rest-wallet-backend

Three fixes from the PR review:

- lndrest: compare lnd's reported network against the configured network
  in lnd's naming convention instead of chaincfg.Params.Name. lnd
  reports the testnet3 network as "testnet" while chaincfg names it
  "testnet3", so the old check rejected a correctly-configured testnet
  node at connect time (regtest/mainnet/signet happen to match, which is
  why the systests never caught it). The comparison is now a small
  networkMismatch helper with a unit test covering the testnet3 case.
- waved: require lnd.macaroonpath in Config.Validate() when
  lnd.transport is rest, so the missing-macaroon failure surfaces at
  config-validation time rather than later at connect time.
- lndrest: decode the NextAddr response against the configured chain
  params rather than nil, matching ImportTaprootScript and rejecting a
  network-mismatched address.
@levmi levmi added rpc RPC transport and protobuf wallet labels Jul 27, 2026
@litbot-9000

Copy link
Copy Markdown
Collaborator

@ellemouton, remember to re-request review from reviewers when ready

1 similar comment
@litbot-9000

Copy link
Copy Markdown
Collaborator

@ellemouton, remember to re-request review from reviewers when ready

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

Labels

rpc RPC transport and protobuf wallet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants