Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
19 changes: 18 additions & 1 deletion docs/wavewalletdk_mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@ 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 values above five minutes are
rejected. A binding-owned deadline or lifecycle cancellation returns an error
beginning `receive outcome uncertain; reconcile Activity before retrying`. It
does not prove that the receive session was not created. The host must reconcile
the authoritative Activity view and recover any matching invoice before
deliberately asking to create another one. Repeatable reads (`GetInfo`,
`Balance`, `List`, `ExitStatus`, `ExitSummary`, `GetExitPlan`, `Status`, and the
scalar balance/readiness helpers) 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 +105,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
19 changes: 19 additions & 0 deletions sdk/wavewalletdk/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ application-facing wallet API.
`applyMobileConfig` into a `wavewalletdk.Config`; validated (non-negative
durations/counts, `uint32`-safe recovery window) before merging onto
`wavewalletdk.DefaultConfig()`.
- `mobileReceiveRequest` — unexported wire DTO carrying the SDK's current
`AmountSat`/`Memo` fields plus the mobile-only `TimeoutSeconds`. It is
projected onto `wavewalletdk.ReceiveRequest` after deadline validation.
- RPC verbs (`GetInfo`, `CreateWallet`, `UnlockWallet`, `Balance`, `Deposit`,
`Receive`, `PrepareSend`, `SendPrepared`, `List`, `Exit`, `ExitStatus`,
`ExitSummary`, `GetExitPlan`, `SweepWallet`, `Status`, `Subscribe`,
`OpenWalletFromPasskey`) — each dereferences the singleton `wavewalletdk.Client`
via `activeClient()`, decodes a JSON request into the matching
`wavewalletdk.*Request`, and marshals the `wavewalletdk.*Result` response.
`Receive` is the exception: it decodes `mobileReceiveRequest` first so the
binding can own its request deadline without changing the SDK DTO.
- Scalar conveniences (`ConfirmedBalanceSat`, `PendingInboundSat`,
`WalletReady`, `IsRunning`) — avoid a JSON round trip for hot-path reads;
`IsRunning` never blocks on an RPC.
Expand All @@ -57,6 +62,18 @@ application-facing wallet API.
can succeed (e.g. after OS suspend/resume).
- `startEmbedded` separates its daemon-readiness deadline from the lifecycle
context used to open or recover the wallet. `Stop` cancels both.
- gomobile cannot carry a caller `context.Context`, so this package owns
per-call deadlines. Repeatable reads (`GetInfo`, `Balance`, `List`,
`ExitStatus`, `ExitSummary`, `GetExitPlan`, `Status`, and the scalar
balance/readiness helpers) use `readContext`'s 10-second deadline. `Receive`
uses `TimeoutSeconds`, defaults to 20 seconds when zero or omitted, and
rejects negative values or values above five minutes.
- A `Receive` canceled by its binding-owned deadline or the parent lifecycle is
returned with the stable `receiveUncertainErrorPrefix`. The result is
uncertain: the host must reconcile Activity before deliberately requesting
another invoice.
- Every bounded call context derives from the daemon-lifetime parent returned
by `activeClient`, so `Stop` still cancels in-flight calls immediately.
- Startup through `startEmbedded` and `Subscription.Next` recover panics into
a returned `error`; those are the entry points documented to survive a panic
without crossing the gomobile boundary and killing the host process.
Expand All @@ -70,6 +87,8 @@ application-facing wallet API.

## Deep Docs

- [docs/wavewalletdk_mobile.md](../../../docs/wavewalletdk_mobile.md) — Binding
ABI, JSON field casing, per-call deadlines, and host lifecycle rules.
- [sdk/wavewalletdk/CLAUDE.md](../CLAUDE.md) — Wrapped SDK; see for full DTO and
RPC method detail.
- [ARCHITECTURE.md](../../../ARCHITECTURE.md) — System-wide package map.
19 changes: 19 additions & 0 deletions sdk/wavewalletdk/mobile/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ application-facing wallet API.
`applyMobileConfig` into a `wavewalletdk.Config`; validated (non-negative
durations/counts, `uint32`-safe recovery window) before merging onto
`wavewalletdk.DefaultConfig()`.
- `mobileReceiveRequest` — unexported wire DTO carrying the SDK's current
`AmountSat`/`Memo` fields plus the mobile-only `TimeoutSeconds`. It is
projected onto `wavewalletdk.ReceiveRequest` after deadline validation.
- RPC verbs (`GetInfo`, `CreateWallet`, `UnlockWallet`, `Balance`, `Deposit`,
`Receive`, `PrepareSend`, `SendPrepared`, `List`, `Exit`, `ExitStatus`,
`ExitSummary`, `GetExitPlan`, `SweepWallet`, `Status`, `Subscribe`,
`OpenWalletFromPasskey`) — each dereferences the singleton `wavewalletdk.Client`
via `activeClient()`, decodes a JSON request into the matching
`wavewalletdk.*Request`, and marshals the `wavewalletdk.*Result` response.
`Receive` is the exception: it decodes `mobileReceiveRequest` first so the
binding can own its request deadline without changing the SDK DTO.
- Scalar conveniences (`ConfirmedBalanceSat`, `PendingInboundSat`,
`WalletReady`, `IsRunning`) — avoid a JSON round trip for hot-path reads;
`IsRunning` never blocks on an RPC.
Expand All @@ -57,6 +62,18 @@ application-facing wallet API.
can succeed (e.g. after OS suspend/resume).
- `startEmbedded` separates its daemon-readiness deadline from the lifecycle
context used to open or recover the wallet. `Stop` cancels both.
- gomobile cannot carry a caller `context.Context`, so this package owns
per-call deadlines. Repeatable reads (`GetInfo`, `Balance`, `List`,
`ExitStatus`, `ExitSummary`, `GetExitPlan`, `Status`, and the scalar
balance/readiness helpers) use `readContext`'s 10-second deadline. `Receive`
uses `TimeoutSeconds`, defaults to 20 seconds when zero or omitted, and
rejects negative values or values above five minutes.
- A `Receive` canceled by its binding-owned deadline or the parent lifecycle is
returned with the stable `receiveUncertainErrorPrefix`. The result is
uncertain: the host must reconcile Activity before deliberately requesting
another invoice.
- Every bounded call context derives from the daemon-lifetime parent returned
by `activeClient`, so `Stop` still cancels in-flight calls immediately.
- Startup through `startEmbedded` and `Subscription.Next` recover panics into
a returned `error`; those are the entry points documented to survive a panic
without crossing the gomobile boundary and killing the host process.
Expand All @@ -70,6 +87,8 @@ application-facing wallet API.

## Deep Docs

- [docs/wavewalletdk_mobile.md](../../../docs/wavewalletdk_mobile.md) — Binding
ABI, JSON field casing, per-call deadlines, and host lifecycle rules.
- [sdk/wavewalletdk/CLAUDE.md](../CLAUDE.md) — Wrapped SDK; see for full DTO and
RPC method detail.
- [ARCHITECTURE.md](../../../ARCHITECTURE.md) — System-wide package map.
12 changes: 9 additions & 3 deletions sdk/wavewalletdk/mobile/convenience.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ package mobile

// ConfirmedBalanceSat returns the confirmed wallet balance in satoshis.
func ConfirmedBalanceSat() (int64, error) {
client, ctx, err := activeClient()
client, parentCtx, err := activeClient()
if err != nil {
return 0, err
}
ctx, cancel := readContext(parentCtx)
defer cancel()

bal, err := client.Balance(ctx)
if err != nil {
Expand All @@ -23,10 +25,12 @@ func ConfirmedBalanceSat() (int64, error) {

// PendingInboundSat returns the pending inbound balance in satoshis.
func PendingInboundSat() (int64, error) {
client, ctx, err := activeClient()
client, parentCtx, err := activeClient()
if err != nil {
return 0, err
}
ctx, cancel := readContext(parentCtx)
defer cancel()

bal, err := client.Balance(ctx)
if err != nil {
Expand All @@ -39,10 +43,12 @@ func PendingInboundSat() (int64, error) {
// WalletReady reports whether the daemon wallet is fully unlocked and ready to
// sign. It is the scalar form of GetInfo().WalletState == ready.
func WalletReady() (bool, error) {
client, ctx, err := activeClient()
client, parentCtx, err := activeClient()
if err != nil {
return false, err
}
ctx, cancel := readContext(parentCtx)
defer cancel()

info, err := client.GetInfo(ctx)
if err != nil {
Expand Down
105 changes: 105 additions & 0 deletions sdk/wavewalletdk/mobile/mobile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,119 @@
package mobile

import (
"context"
"encoding/json"
"errors"
"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)
}
}

// 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) {
Expand Down
Loading
Loading