Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 15 additions & 1 deletion docs/wavewalletdk_mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,14 @@ not snake_case. Host models must map those exact names (e.g. `@SerialName`
(`data_dir`, `wallet_esplora_url`, …), and `OpenWalletFromPasskey`, whose
request is a small camelCase-tagged struct (`prfOutput`). Every other request
decodes into the matching `wavewalletdk.*Request` DTO, so it follows the
PascalCase rule.
PascalCase rule. `Receive` additionally accepts `TimeoutSeconds`: zero or an
omitted field selects a 20-second default, and positive values are capped at
five minutes. A deadline error does not prove that the receive session was not

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F3 (Minor) — Doc says over-cap TimeoutSeconds is clamped, not rejected · docs/wavewalletdk_mobile.md:57

decodeReceiveRequest returns an error when TimeoutSeconds exceeds 300 (sdk/wavewalletdk/mobile/wallet.go:67), but this line tells host authors that positive values are "capped at five minutes", which reads as clamping — a host that sends 600 gets a failed Receive, not a 300-second one.

Suggested change
five minutes. A deadline error does not prove that the receive session was not
omitted field selects a 20-second default, and values are rejected above

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5183f572. The binding documentation now says values above five minutes are rejected, matching decodeReceiveRequest and its negative/excessive/overflow tests.

created. The host must reconcile the authoritative Activity view and recover
any matching invoice before deliberately asking to create another one.
Repeatable `Balance`, `List`, and `Status` reads use a 10-second binding-owned
deadline because gomobile cannot carry a caller `context.Context`; these reads
are safe for the host to request again after timeout.

`StartExternalSeedWallet` is another explicit exception in the private binding
ABI. Its startup envelope is snake_case (`config`, `seed_entropy`,
Expand Down Expand Up @@ -95,6 +102,13 @@ func Stop() error
and in-flight RPCs / subscriptions unwind on shutdown.
- The startup deadline bounds daemon readiness only. External-seed opening and
recovery use the lifecycle context cancelled by `Stop`.
- The portable Go binding cannot observe an iOS or Android application
lifecycle. A host that leaves the embedded daemon alive while its process is
suspended also leaves external gRPC connections frozen on their old network
path. After a real background/resume transition, call `Stop` and `Start`
(then unlock the same durable wallet) so external transports are re-dialled.
The lifecycle state machine prevents overlapping daemon instances; it does
not infer foreground state for the host.

### External seed wallets

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/btcsuite/btcd v0.26.0
github.com/btcsuite/btcd/btcec/v2 v2.5.0
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b
github.com/btcsuite/btcwallet v0.18.0
github.com/btcsuite/btcwallet v0.18.1-0.20260826052527-33c252f3b4d6
github.com/btcsuite/btcwallet/walletdb v1.6.0
github.com/btcsuite/btcwallet/wtxmgr v1.6.0
github.com/golang-migrate/migrate/v4 v4.19.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -658,8 +658,8 @@ github.com/btcsuite/btclog v1.0.0 h1:sEkpKJMmfGiyZjADwEIgB1NSwMyfdD1FB8v6+w1T0Ns
github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ=
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b h1:MQ+Q6sDy37V1wP1Yu79A5KqJutolqUGwA99UZWQDWZM=
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b/go.mod h1:XItGUfVOxotJL8kkuk2Hj3EVow5KCugXl3wWfQ6K0AE=
github.com/btcsuite/btcwallet v0.18.0 h1:VSRClNLT7NX0wmJEGALz3jOZRRjWPpUdp7VI1Akie1o=
github.com/btcsuite/btcwallet v0.18.0/go.mod h1:1ZMc1EEskov+AKKv4kCMZqN8BwVh9rpXwEyxbeWy2A4=
github.com/btcsuite/btcwallet v0.18.1-0.20260826052527-33c252f3b4d6 h1:A49O49JWTm2xaI3lWWtlYAQ8dpM3eMCu6JV52AWXOhQ=
github.com/btcsuite/btcwallet v0.18.1-0.20260826052527-33c252f3b4d6/go.mod h1:1ZMc1EEskov+AKKv4kCMZqN8BwVh9rpXwEyxbeWy2A4=
github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0 h1:oIkGj32YK1CvWaJGlVwZA1f+y/KVHkfrd2PoST0ZpQs=
github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0/go.mod h1:sGrBjcqQ8UPexuRajFs72+o544CJn3Pavv/5H0VAWVk=
github.com/btcsuite/btcwallet/wallet/txrules v1.3.0 h1:D5aGMwWIxdqek3xEJs4eOdMoh6iga2EI2xSlaXCdnNo=
Expand Down
68 changes: 68 additions & 0 deletions sdk/wavewalletdk/mobile/mobile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,78 @@ import (
"fmt"
"strings"
"testing"
"time"

"github.com/lightninglabs/wavelength/sdk/wavewalletdk"
)

// TestDecodeReceiveRequestUsesBoundedDefault verifies older hosts that omit
// TimeoutSeconds still receive a finite deadline without changing the SDK DTO.
func TestDecodeReceiveRequestUsesBoundedDefault(t *testing.T) {
req, timeout, err := decodeReceiveRequest([]byte(
`{"AmountSat":21000,"Memo":"coffee"}`,
))
if err != nil {
t.Fatalf("decode receive request: %v", err)
}
if req.AmountSat != 21_000 || req.Memo != "coffee" {
t.Fatalf("unexpected receive request: %+v", req)
}
if timeout != defaultReceiveTimeout {
t.Fatalf("timeout = %v, want %v", timeout,
defaultReceiveTimeout)
}
}

// TestDecodeReceiveRequestAcceptsExplicitDeadline verifies a mobile host can
// choose a shorter bounded foreground deadline for invoice creation.
func TestDecodeReceiveRequestAcceptsExplicitDeadline(t *testing.T) {
_, timeout, err := decodeReceiveRequest([]byte(
`{"AmountSat":1000,"TimeoutSeconds":12}`,
))
if err != nil {
t.Fatalf("decode receive request: %v", err)
}
if timeout != 12*time.Second {
t.Fatalf("timeout = %v, want 12s", timeout)
}
}

// TestDecodeReceiveRequestRejectsInvalidDeadlines verifies malformed negative
// or excessive values cannot restore an unbounded receive call.
func TestDecodeReceiveRequestRejectsInvalidDeadlines(t *testing.T) {
for name, body := range map[string]string{
"negative": `{"AmountSat":1000,"TimeoutSeconds":-1}`,
"excessive": `{"AmountSat":1000,"TimeoutSeconds":301}`,
"overflow": `{"AmountSat":1000,"TimeoutSeconds":9223372036854775807}`,
} {
t.Run(name, func(t *testing.T) {
if _, _, err := decodeReceiveRequest(
[]byte(body),
); err == nil {

t.Fatal("expected invalid timeout error")
}
})
}
}

// TestReadContextHasDeadline verifies repeatable mobile reads never inherit the
// full daemon lifetime when a foreign host cannot provide context.Context.
func TestReadContextHasDeadline(t *testing.T) {
ctx, cancel := readContext(t.Context())
defer cancel()

deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("read context has no deadline")
}
remaining := time.Until(deadline)
if remaining <= 0 || remaining > defaultReadTimeout {
t.Fatalf("read deadline remaining = %v", remaining)
}
}

// TestParseConfigEmptyUsesDefaults verifies that an empty config string yields
// the wavewalletdk defaults rather than a zero config.
func TestParseConfigEmptyUsesDefaults(t *testing.T) {
Expand Down
94 changes: 86 additions & 8 deletions sdk/wavewalletdk/mobile/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,77 @@ import (
"fmt"
"io"
"runtime/debug"
"time"

"github.com/lightninglabs/wavelength/sdk/wavewalletdk"
)

const (
// defaultReadTimeout bounds read-only wallet calls whose foreign host
// cannot supply context.Context through gomobile. Reads are safe to
// repeat after a timeout and must not inherit the daemon's entire
// lifetime.
defaultReadTimeout = 10 * time.Second

// defaultReceiveTimeout bounds invoice creation when a mobile host
// omits TimeoutSeconds. A timed-out request has an uncertain outcome:
// callers must reconcile Activity before deliberately creating another
// invoice.
defaultReceiveTimeout = 20 * time.Second

// maxReceiveTimeout prevents malformed host input from restoring the
// effectively unbounded behavior this mobile-only request field avoids.
maxReceiveTimeout = 5 * time.Minute
)

// readContext derives the bounded context used by safe, repeatable mobile
// reads. The daemon-lifetime parent still cancels it immediately during Stop.
func readContext(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, defaultReadTimeout)
}

// mobileReceiveRequest extends the SDK receive DTO with a mobile-only request
// deadline. The extra JSON field is backwards-compatible with older bindings,
// whose encoding/json decoder ignores it.
type mobileReceiveRequest struct {

@lightninglabs-gateway lightninglabs-gateway Bot Aug 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚫 Dismissed by @bhandras

🟡 F1 (Minor) — Mobile Receive drops any ReceiveRequest field beyond two · sdk/wavewalletdk/mobile/wallet.go:43

mobileReceiveRequest hand-copies AmountSat and Memo rather than embedding wavewalletdk.ReceiveRequest, so any other field on that DTO — today or after a future SDK change — is silently dropped at the binding instead of failing to compile; I cannot enumerate the DTO's fields because sdk/wavewalletdk/types.go is not in the loaded context, so this may be latent rather than live. Embedding (struct { wavewalletdk.ReceiveRequest; TimeoutSeconds int64 }) preserves the flat PascalCase wire shape, since encoding/json promotes embedded struct fields, and lets Receive forward the whole DTO.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not worth changing. wavewalletdk.ReceiveRequest currently has exactly AmountSat and Memo (sdk/wavewalletdk/types.go:191). No current field is dropped. This binding maps the mobile-only timeout at the owning boundary. Embedding would silently opt future SDK fields into the mobile wire ABI instead of forcing deliberate binding review, so there is no concrete defect to fix here.

AmountSat uint64
Memo string
TimeoutSeconds int64
}

// decodeReceiveRequest validates the mobile-only deadline and converts the
// wire request into the SDK DTO. Zero selects the bounded default so older
// hosts gain a deadline when they update only the native framework.
func decodeReceiveRequest(reqJSON []byte) (wavewalletdk.ReceiveRequest,
time.Duration, error) {

var mobileReq mobileReceiveRequest
if err := decode(reqJSON, &mobileReq); err != nil {
return wavewalletdk.ReceiveRequest{}, 0, err
}

timeout := defaultReceiveTimeout
if mobileReq.TimeoutSeconds < 0 {
return wavewalletdk.ReceiveRequest{}, 0,
fmt.Errorf("receive timeout seconds must not be " +
"negative")
}
maxTimeoutSeconds := int64(maxReceiveTimeout / time.Second)
if mobileReq.TimeoutSeconds > maxTimeoutSeconds {
return wavewalletdk.ReceiveRequest{}, 0, fmt.Errorf("receive "+
"timeout seconds %d exceeds maximum %d",
mobileReq.TimeoutSeconds, maxTimeoutSeconds)
}
if mobileReq.TimeoutSeconds > 0 {
timeout = time.Duration(mobileReq.TimeoutSeconds) * time.Second
}

return wavewalletdk.ReceiveRequest{
AmountSat: mobileReq.AmountSat,
Memo: mobileReq.Memo,
}, timeout, nil
}

// GetInfo returns the daemon readiness snapshot as JSON (wavewalletdk.Info).
func GetInfo() ([]byte, error) {
client, ctx, err := activeClient()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F2 (Minor) — GetInfo/ExitStatus/ExitSummary keep the unbounded deadline · sdk/wavewalletdk/mobile/wallet.go:84

GetInfo, ExitStatus, and ExitSummary are read-only and just as safe for a host to request again, but they still pass activeClient()'s daemon-lifetime context straight through, so they stall on a frozen transport exactly the way Balance did before this PR. Either route them through readContext as well, or note in the binding doc why readiness polling is deliberately exempt.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5183f572. readContext now bounds GetInfo, ExitStatus, ExitSummary, and GetExitPlan, plus the scalar balance/readiness helpers. Balance, List, and Status remain bounded as before. Focused tests, changed-file lint, docs, module checks, and the iOS XCFramework build pass.

Expand Down Expand Up @@ -73,10 +140,12 @@ func UnlockWallet(reqJSON []byte) ([]byte, error) {

// Balance returns the wallet balance summary as JSON (wavewalletdk.Balance).
func Balance() ([]byte, error) {
client, ctx, err := activeClient()
client, parentCtx, err := activeClient()
if err != nil {
return nil, err
}
ctx, cancel := readContext(parentCtx)
defer cancel()

bal, err := client.Balance(ctx)
if err != nil {
Expand Down Expand Up @@ -107,19 +176,24 @@ func Deposit(reqJSON []byte) ([]byte, error) {
return marshal(res)
}

// Receive opens a Lightning invoice receive. reqJSON decodes to
// wavewalletdk.ReceiveRequest; the response is wavewalletdk.ReceiveResult.
// Receive opens a Lightning invoice receive. reqJSON accepts AmountSat, Memo,
// and an optional mobile-only TimeoutSeconds; the response is
// wavewalletdk.ReceiveResult. When the deadline expires, the outcome may be
// uncertain, so the host must reconcile Activity before retrying deliberately.
func Receive(reqJSON []byte) ([]byte, error) {
client, ctx, err := activeClient()
client, parentCtx, err := activeClient()
if err != nil {
return nil, err
}

var req wavewalletdk.ReceiveRequest
if err := decode(reqJSON, &req); err != nil {
req, timeout, err := decodeReceiveRequest(reqJSON)
if err != nil {
return nil, err
}

ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel()

res, err := client.Receive(ctx, req)
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F4 (Minor) — Timed-out Receive is indistinguishable from other errors · sdk/wavewalletdk/mobile/wallet.go:198

The deadline error is returned verbatim, so a host cannot separate "timed out, outcome uncertain, reconcile Activity first" from "rejected, no session created" without matching on an error string that crosses gomobile as free text; whether the SDK already tags deadline errors is not visible here, since sdk/wavewalletdk is not in the loaded context. Wrapping the parentCtx.Err() == nil && ctx.Err() == context.DeadlineExceeded case in a stable, documented message would make the PR's stated safety rule mechanically checkable by the host rather than prose-only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5183f572. A binding-owned deadline now returns the stable receive timed out; outcome uncertain; reconcile Activity before retrying prefix while wrapping the original cause. Parent lifecycle cancellation remains unchanged. Tests cover both branches, and the ABI documentation names the prefix.

return nil, err
Expand Down Expand Up @@ -175,10 +249,12 @@ func SendPrepared(reqJSON []byte) ([]byte, error) {
// decodes to wavewalletdk.ListRequest; the response is the tagged-union
// wavewalletdk.ListResult.
func List(reqJSON []byte) ([]byte, error) {
client, ctx, err := activeClient()
client, parentCtx, err := activeClient()
if err != nil {
return nil, err
}
ctx, cancel := readContext(parentCtx)
defer cancel()

var req wavewalletdk.ListRequest
if err := decode(reqJSON, &req); err != nil {
Expand Down Expand Up @@ -305,10 +381,12 @@ func SweepWallet(reqJSON []byte) ([]byte, error) {
// Status returns wallet readiness, balance, and pending counts as JSON
// (wavewalletdk.Status).
func Status() ([]byte, error) {
client, ctx, err := activeClient()
client, parentCtx, err := activeClient()
if err != nil {
return nil, err
}
ctx, cancel := readContext(parentCtx)
defer cancel()

status, err := client.Status(ctx)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions swapwallet/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ default builds avoid the swap executor's dependency graph.
mid-leave. Confirmed-but-not-yet-boarded UTXOs must NOT inflate
`confirmed_sat` (issue #502), and adopted-but-not-yet-live VTXOs
must stay pending inbound until commitment confirmation (issue #542).
Optional remote credit enrichment has its own short deadline and degrades to
the local satoshi snapshot when unavailable; a stalled credit connection
must never hold an otherwise valid wallet refresh open indefinitely.

## Deep Docs

Expand Down
3 changes: 3 additions & 0 deletions swapwallet/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ default builds avoid the swap executor's dependency graph.
mid-leave. Confirmed-but-not-yet-boarded UTXOs must NOT inflate
`confirmed_sat` (issue #502), and adopted-but-not-yet-live VTXOs
must stay pending inbound until commitment confirmation (issue #542).
Optional remote credit enrichment has its own short deadline and degrades to
the local satoshi snapshot when unavailable; a stalled credit connection
must never hold an otherwise valid wallet refresh open indefinitely.

## Deep Docs

Expand Down
30 changes: 23 additions & 7 deletions swapwallet/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ package swapwallet
import (
"context"
"fmt"
"time"

"github.com/lightninglabs/wavelength/rpc/swapclientrpc"
"github.com/lightninglabs/wavelength/rpc/wavewalletrpc"
"github.com/lightninglabs/wavelength/waverpc"
"google.golang.org/protobuf/encoding/protojson"
)

// defaultCreditReadTimeout bounds optional remote credit enrichment during a
// balance read. The local satoshi balance remains useful when this call times
// out, so refresh paths must not wait on remote connectivity indefinitely.
const defaultCreditReadTimeout = 2 * time.Second

// Service implements the daemon-side WalletService gRPC handler. It is a
// thin facade: every method translates the proto request into typed internal
// calls (router, recv, history, runtime) and returns a normalized response.
Expand All @@ -24,6 +30,8 @@ type Service struct {
router *router
recv *receiver
history *history

creditReadTimeout time.Duration
}

// newService builds the Service handle given its composed dependencies and
Expand All @@ -32,11 +40,12 @@ type Service struct {
// pure wiring.
func newService(deps *Deps, runtime *Runtime) *Service {
return &Service{
deps: deps,
runtime: runtime,
router: newRouter(deps, runtime),
recv: newReceiver(deps, runtime),
history: newHistory(deps, runtime),
deps: deps,
runtime: runtime,
router: newRouter(deps, runtime),
recv: newReceiver(deps, runtime),
history: newHistory(deps, runtime),
creditReadTimeout: defaultCreditReadTimeout,
}
}

Expand Down Expand Up @@ -558,7 +567,9 @@ func gapResponse(cursor int64) *wavewalletrpc.SubscribeWalletResponse {
}

// fetchBalance is the shared helper that pulls the daemon's GetBalance and
// projects its richer breakdown onto the flat wallet shape.
// projects its richer breakdown onto the flat wallet shape. Optional credit
// enrichment has its own short deadline; when it is unavailable, the method
// still returns the authoritative local satoshi balance.
func (s *Service) fetchBalance(ctx context.Context) (
*wavewalletrpc.BalanceResponse, error) {

Expand Down Expand Up @@ -605,8 +616,13 @@ func (s *Service) fetchBalance(ctx context.Context) (
return resp, nil
}

creditCtx, cancel := context.WithTimeout(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F7 (Minor) — Credit timeout is indistinguishable from zero credits · swapwallet/service.go:619

A credit lookup that exceeds the new 2-second bound returns resp, nil with CreditAvailableSat/CreditReservedSat left at zero and nothing logged, so a slow-but-healthy credit endpoint reads identically to a wallet holding no credits — where before this change the same endpoint at 3s latency returned the real figures. Log the elided enrichment via s.deps.resolveLog() on that branch, as replayEvents does for a skipped row, so an operator can tell degradation from a genuine zero; I cannot see rpc/wavewalletrpc's proto in the provided context, so whether the response shape could also carry an explicit "credit unknown" signal is unverified here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 502fa87d.

The bounded credit lookup still degrades to the authoritative local balance, but the fallback now emits Credit balance enrichment skipped with the original error. The regression test verifies both the valid local response and the operator-visible warning.

ctx, s.creditReadTimeout,

@lightninglabs-gateway lightninglabs-gateway Bot Aug 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚫 Dismissed by @bhandras

🟡 F5 (Minor) — Zero-value creditReadTimeout disables credit enrichment · swapwallet/service.go:620

creditReadTimeout is populated only in newService, so a Service constructed any other way gets 0, and context.WithTimeout(ctx, 0) yields an already-expired context — every balance read then silently omits the credit fields with no error surfaced. The package's other wallet-level knobs resolve through Deps (resolveMaxListLimit, resolveLog); a resolveCreditReadTimeout() falling back to defaultCreditReadTimeout matches that convention and fails safe.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not worth changing. The production path is swapwallet.RegisternewService, which always sets creditReadTimeout; all package service fixtures also use newService. The dependencies are private, and a zero-value Service has nil dependencies and cannot serve a balance request. There is no production path to an otherwise initialized service with a zero timeout, so a fallback would defend an unreachable state.

)
defer cancel()

credits, err := s.deps.SwapService.ListCredits(
ctx, &swapclientrpc.ListCreditsRequest{
creditCtx, &swapclientrpc.ListCreditsRequest{
Limit: 1,
},
)
Expand Down
Loading
Loading