diff --git a/db/AGENTS.md b/db/AGENTS.md index 27dec9284..dfacb0122 100644 --- a/db/AGENTS.md +++ b/db/AGENTS.md @@ -156,6 +156,9 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/db. defaultReadTimeout { + t.Fatalf("read deadline remaining = %v", remaining) + } +} + +// TestReceiveErrorMarksUncertainTimeout verifies a binding-owned receive +// deadline has a stable host-visible prefix and preserves the original cause. +func TestReceiveErrorMarksUncertainTimeout(t *testing.T) { + parentCtx := context.Background() + callCtx, cancel := context.WithTimeout(parentCtx, 0) + defer cancel() + + err := receiveError(callCtx, context.DeadlineExceeded) + if !strings.HasPrefix(err.Error(), receiveUncertainErrorPrefix) { + t.Fatalf("receive error = %q", err) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("receive error lost deadline cause: %v", err) + } +} + +// TestReceiveErrorMarksLifecycleCancellation verifies Stop cancellation has +// the same reconcile-before-retry marker as a binding-owned deadline. +func TestReceiveErrorMarksLifecycleCancellation(t *testing.T) { + parentCtx, cancelParent := context.WithCancel(context.Background()) + callCtx, cancelCall := context.WithTimeout(parentCtx, time.Minute) + defer cancelCall() + cancelParent() + <-callCtx.Done() + + want := errors.New("wallet stopped") + got := receiveError(callCtx, want) + if !strings.HasPrefix(got.Error(), receiveUncertainErrorPrefix) { + t.Fatalf("receive error = %q", got) + } + if !errors.Is(got, want) { + t.Fatalf("receive error lost lifecycle cause: %v", got) + } +} + // TestParseConfigEmptyUsesDefaults verifies that an empty config string yields // the wavewalletdk defaults rather than a zero config. func TestParseConfigEmptyUsesDefaults(t *testing.T) { diff --git a/sdk/wavewalletdk/mobile/wallet.go b/sdk/wavewalletdk/mobile/wallet.go index 1159b6ce9..e7657cfd4 100644 --- a/sdk/wavewalletdk/mobile/wallet.go +++ b/sdk/wavewalletdk/mobile/wallet.go @@ -8,16 +8,101 @@ 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 + + // receiveUncertainErrorPrefix is stable binding ABI. It lets a foreign + // host distinguish a canceled receive with an uncertain outcome from a + // rejected request without depending on context-specific error text. + receiveUncertainErrorPrefix = "receive outcome uncertain; reconcile " + + "Activity before retrying" +) + +// 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) +} + +// receiveError preserves ordinary receive errors while marking cancellation by +// either the binding-owned deadline or Stop as an uncertain outcome. +func receiveError(callCtx context.Context, err error) error { + if err == nil || callCtx.Err() == nil { + return err + } + + return fmt.Errorf("%s: %w", receiveUncertainErrorPrefix, err) +} + +// 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 { + 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() + client, parentCtx, err := activeClient() if err != nil { return nil, err } + ctx, cancel := readContext(parentCtx) + defer cancel() info, err := client.GetInfo(ctx) if err != nil { @@ -73,10 +158,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 { @@ -107,22 +194,27 @@ 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 { - return nil, err + return nil, receiveError(ctx, err) } return marshal(res) @@ -175,10 +267,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 { @@ -218,10 +312,12 @@ func Exit(reqJSON []byte) ([]byte, error) { // wavewalletdk.ExitStatusRequest; the response is // wavewalletdk.ExitStatusResult. func ExitStatus(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.ExitStatusRequest if err := decode(reqJSON, &req); err != nil { @@ -240,10 +336,12 @@ func ExitStatus(reqJSON []byte) ([]byte, error) { // decodes to wavewalletdk.ExitSummaryRequest (an empty object is fine); the // response is wavewalletdk.ExitSummaryResult. func ExitSummary(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.ExitSummaryRequest if err := decode(reqJSON, &req); err != nil { @@ -262,10 +360,12 @@ func ExitSummary(reqJSON []byte) ([]byte, error) { // decodes to wavewalletdk.GetExitPlanRequest; the response is // wavewalletdk.GetExitPlanResult. func GetExitPlan(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.GetExitPlanRequest if err := decode(reqJSON, &req); err != nil { @@ -305,10 +405,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 { diff --git a/swapwallet/AGENTS.md b/swapwallet/AGENTS.md index 7b2b0f210..7badfe4a5 100644 --- a/swapwallet/AGENTS.md +++ b/swapwallet/AGENTS.md @@ -204,6 +204,10 @@ 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 with a warning when unavailable; a stalled credit + connection must never hold an otherwise valid wallet refresh open + indefinitely. ## Deep Docs diff --git a/swapwallet/CLAUDE.md b/swapwallet/CLAUDE.md index 7b2b0f210..7badfe4a5 100644 --- a/swapwallet/CLAUDE.md +++ b/swapwallet/CLAUDE.md @@ -204,6 +204,10 @@ 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 with a warning when unavailable; a stalled credit + connection must never hold an otherwise valid wallet refresh open + indefinitely. ## Deep Docs diff --git a/swapwallet/service.go b/swapwallet/service.go index e06e81da4..905fc590b 100644 --- a/swapwallet/service.go +++ b/swapwallet/service.go @@ -5,6 +5,7 @@ package swapwallet import ( "context" "fmt" + "time" "github.com/lightninglabs/wavelength/rpc/swapclientrpc" "github.com/lightninglabs/wavelength/rpc/wavewalletrpc" @@ -12,6 +13,11 @@ import ( "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. @@ -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 @@ -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, } } @@ -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) { @@ -605,12 +616,23 @@ func (s *Service) fetchBalance(ctx context.Context) ( return resp, nil } + creditCtx, cancel := context.WithTimeout( + ctx, s.creditReadTimeout, + ) + defer cancel() + credits, err := s.deps.SwapService.ListCredits( - ctx, &swapclientrpc.ListCreditsRequest{ + creditCtx, &swapclientrpc.ListCreditsRequest{ Limit: 1, }, ) if err != nil { + s.deps.resolveLog().WarnS( + ctx, + "Credit balance enrichment skipped", + err, + ) + return resp, nil } diff --git a/swapwallet/service_test.go b/swapwallet/service_test.go index 286b56ab4..bced1a72d 100644 --- a/swapwallet/service_test.go +++ b/swapwallet/service_test.go @@ -3,9 +3,12 @@ package swapwallet import ( + "bytes" "context" "testing" + "time" + "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/rpc/swapclientrpc" "github.com/lightninglabs/wavelength/rpc/wavewalletrpc" "github.com/lightninglabs/wavelength/waverpc" @@ -14,6 +17,23 @@ import ( "google.golang.org/grpc/status" ) +// blockingCreditService models an external credit endpoint whose transport +// accepts a request but never responds until the caller's context expires. +type blockingCreditService struct { + *fakeSwapService +} + +// ListCredits pins the incident shape by waiting only for caller cancellation; +// it must not hold the local wallet balance open beyond its enrichment bound. +func (b *blockingCreditService) ListCredits(ctx context.Context, + _ *swapclientrpc.ListCreditsRequest) ( + *swapclientrpc.ListCreditsResponse, error) { + + <-ctx.Done() + + return nil, ctx.Err() +} + // newServiceFixture builds a Service with fake deps so each gRPC handler // can be exercised without a real daemon. func newServiceFixture(t *testing.T) (*Service, *fakeSwapService, @@ -321,6 +341,8 @@ func TestServiceBalanceSurfacesInFlightVTXOs(t *testing.T) { } } +// TestServiceBalanceIncludesCredits verifies a healthy remote credit snapshot +// enriches the authoritative local satoshi balance without replacing it. func TestServiceBalanceIncludesCredits(t *testing.T) { t.Parallel() @@ -344,6 +366,59 @@ func TestServiceBalanceIncludesCredits(t *testing.T) { require.Equal(t, uint32(1), swap.listCreditsLast.GetLimit()) } +// TestServiceBalanceDegradesWhenCreditsAreUnavailable verifies optional remote +// credit enrichment cannot turn an otherwise valid local balance snapshot into +// a refresh error. A later refresh may fill in the omitted credit fields. +func TestServiceBalanceDegradesWhenCreditsAreUnavailable(t *testing.T) { + t.Parallel() + + svc, swap, rpc := newServiceFixture(t) + var logBuf bytes.Buffer + svc.deps.Log = btclog.NewSLogger( + btclog.NewDefaultHandler( + &logBuf, btclog.WithNoTimestamp(), + ), + ) + rpc.getBalanceResp = &waverpc.GetBalanceResponse{ + VtxoBalanceSat: 75_000, + } + swap.listCreditsErr = context.DeadlineExceeded + + resp, err := svc.Balance( + t.Context(), &wavewalletrpc.BalanceRequest{}, + ) + require.NoError(t, err) + require.Equal(t, int64(75_000), resp.GetConfirmedSat()) + require.Zero(t, resp.GetCreditAvailableSat()) + require.Zero(t, resp.GetCreditReservedSat()) + require.Contains( + t, logBuf.String(), + "Credit balance enrichment skipped", + ) + require.Contains(t, logBuf.String(), context.DeadlineExceeded.Error()) +} + +// TestServiceBalanceBoundsStalledCreditRead verifies a transport that never +// answers cannot suppress the daemon-local balance snapshot indefinitely. +func TestServiceBalanceBoundsStalledCreditRead(t *testing.T) { + svc, swap, rpc := newServiceFixture(t) + rpc.getBalanceResp = &waverpc.GetBalanceResponse{ + VtxoBalanceSat: 75_000, + } + svc.deps.SwapService = &blockingCreditService{ + fakeSwapService: swap, + } + svc.creditReadTimeout = 10 * time.Millisecond + + started := time.Now() + resp, err := svc.Balance( + t.Context(), &wavewalletrpc.BalanceRequest{}, + ) + require.NoError(t, err) + require.Equal(t, int64(75_000), resp.GetConfirmedSat()) + require.Less(t, time.Since(started), time.Second) +} + // TestServiceBalanceKeepsAdoptedBoardingPending pins issue #542: after a // boarding UTXO is adopted into a round, the underlying on-chain UTXO is // spent before the resulting VTXO is live. The balance must keep that value