Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ android/walletkit/libs/*.aar
# iOS gomobile xcframework (built by scripts/fetch-xcframework.sh) + SwiftPM.
ios/WalletKit/Frameworks/*.xcframework
ios/WalletKit/.build/
ios/WalletKit/.swiftpm/

# iOS sample: generated project + build output (xcodegen regenerates).
ios/Sample/*.xcodeproj
Expand Down
87 changes: 87 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
SHELL := /bin/bash

REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
SAMPLE_DIR := $(REPO_ROOT)/ios/Sample
PROJECT := $(SAMPLE_DIR)/Wavelength.xcodeproj
SCHEME := Wavelength
DERIVED_DATA ?= $(SAMPLE_DIR)/DerivedData
SIMULATOR_UDID ?=
DEVICE_UDID ?=
BUNDLE_ID ?= engineering.lightning.wavelength.wallet

.PHONY: help framework generate simulator device device-logs build test run \
check-regtest-env run-regtest test-regtest clean

help: ## Show the available developer commands.
@awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_-]+:.*## / {printf " %-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

framework: ## Fetch or build Wavewalletdk.xcframework when it is missing.
@if [[ ! -d "$(REPO_ROOT)/ios/WalletKit/Frameworks/Wavewalletdk.xcframework" ]]; then \
"$(REPO_ROOT)/scripts/fetch-xcframework.sh"; \
fi

generate: ## Generate the Xcode project with xcodegen.
@cd "$(SAMPLE_DIR)" && xcodegen generate

simulator: ## Print the selected Simulator UDID, booting it if necessary.
@SIMULATOR_UDID="$(SIMULATOR_UDID)" "$(REPO_ROOT)/scripts/select-ios-simulator.sh"

device: ## Print the selected connected physical iOS device UDID.
@DEVICE_UDID="$(DEVICE_UDID)" "$(REPO_ROOT)/scripts/select-ios-device.sh"

device-logs: ## Relaunch the installed app on a phone and stream its live logs.
@DEVICE_UDID="$(DEVICE_UDID)" \
BUNDLE_ID="$(BUNDLE_ID)" \
"$(REPO_ROOT)/scripts/observe-ios-device.sh"

build: framework generate ## Build the app for an automatically selected iPhone Simulator.
@udid="$$(SIMULATOR_UDID="$(SIMULATOR_UDID)" "$(REPO_ROOT)/scripts/select-ios-simulator.sh")"; \
xcodebuild \
-project "$(PROJECT)" \
-scheme "$(SCHEME)" \
-destination "platform=iOS Simulator,id=$$udid" \
-derivedDataPath "$(DERIVED_DATA)" \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGNING_REQUIRED=YES \
CODE_SIGN_IDENTITY=- \
build

test: framework generate ## Run unit tests; live regtest UI tests skip unless enabled.
@udid="$$(SIMULATOR_UDID="$(SIMULATOR_UDID)" "$(REPO_ROOT)/scripts/select-ios-simulator.sh")"; \
xcodebuild \
-project "$(PROJECT)" \
-scheme "$(SCHEME)" \
-destination "platform=iOS Simulator,id=$$udid" \
-derivedDataPath "$(DERIVED_DATA)" \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGNING_REQUIRED=YES \
CODE_SIGN_IDENTITY=- \
test

run: ## Build, install, and launch the app in an iPhone Simulator.
@SIMULATOR_UDID="$(SIMULATOR_UDID)" "$(REPO_ROOT)/scripts/run-ios-sample.sh"

check-regtest-env:
@missing=(); \
for name in WAVELENGTH_OPERATOR_ADDRESS WAVELENGTH_SWAP_ADDRESS WAVELENGTH_ESPLORA_URL; do \
[[ -n "$${!name:-}" ]] || missing+=("$$name"); \
done; \
if (( $${#missing[@]} )); then \
echo "Missing regtest environment variables: $${missing[*]}" >&2; \
echo "Export endpoints for a live local environment before running this target." >&2; \
exit 2; \
fi

run-regtest: check-regtest-env ## Build and run using exported regtest endpoints.
@WAVELENGTH_REGTEST=1 \
SIMULATOR_UDID="$(SIMULATOR_UDID)" \
"$(REPO_ROOT)/scripts/run-ios-sample.sh"

test-regtest: check-regtest-env framework generate ## Run opt-in live UI tests against exported regtest endpoints.
@WAVELENGTH_REGTEST=1 \
WAVELENGTH_UI_REGTEST=1 \
SIMULATOR_UDID="$(SIMULATOR_UDID)" \
$(MAKE) --no-print-directory test

clean: ## Remove generated Xcode and DerivedData output.
@rm -rf "$(PROJECT)" "$(DERIVED_DATA)"
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ embedded wallet, create a key, sync the chain from Esplora, and read balances.
| `android/walletkit/` | Idiomatic Kotlin wrapper (`suspend` + `Flow` + typed models) over the generated bindings. The Android library other apps depend on. |
| `android/app/` | Sample Android app (Jetpack Compose, AGP 9) that drives `walletkit`. |
| `ios/WalletKit/` | Idiomatic Swift wrapper (`actor` + `async` + `AsyncThrowingStream` + `Codable`). Mirrors the Kotlin library. |
| `ios/Sample/` | Native SwiftUI Wavelength wallet with onboarding, balance, send, receive, activity, details, and per-network settings. |
| `scripts/fetch-aar.sh` | Downloads `Wavewalletdk.aar` from the `wavelength` GitHub release (or builds it from a `WAVELENGTH_DIR` checkout) and stages it under `android/walletkit/libs`. |
| `scripts/fetch-xcframework.sh` | Same for the iOS `Wavewalletdk.xcframework`. |
| `Makefile` | One-command iOS Simulator build, test, run, and opt-in live regtest workflows. |
| `docs/` | Architecture, the Android workflow, and signet setup. |

Both the Android and iOS wrappers and their sample apps run end to end — Android
Expand Down Expand Up @@ -126,5 +128,6 @@ design; the full method list is in wavelength's
detail, including the `android` CLI and emulator.
- [`docs/signet.md`](docs/signet.md) — pointing the wallet at a signet
environment and watching it sync.
- [`ios/README.md`](ios/README.md) — the Swift `WalletKit` wrapper and how to
build its `xcframework`.
- [`ios/README.md`](ios/README.md) — the Swift `WalletKit` wrapper, native
wallet, one-command Simulator workflow, and environment-driven regtest
validation.
187 changes: 179 additions & 8 deletions ios/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,176 @@
# iOS — WalletKit
# iOS — Wavelength wallet and WalletKit

The `ios/Sample` target is a native SwiftUI wallet named **Wavelength**. It uses
the `WalletKit` Swift package to run Wavelength inside the app process.

The backing Bitcoin wallet is Wavelength's lightweight `lwwallet` backend. It
syncs blocks and transactions through Esplora. The app does not configure or
run an LND node or use an LND wallet. Lightning payments use Wavelength's Ark
and swap services while keys and Bitcoin funds remain in the embedded,
self-custodial wallet.

The app includes:

- separate signet, testnet3, and mainnet wallets;
- an Esplora-synced confirmed balance and pending amounts;
- Lightning and on-chain receive requests with QR codes, copy, and share;
- Lightning and on-chain sends with camera QR scanning and a quote-and-confirm
step;
- live, searchable activity and a detailed inspection screen;
- recovery-word creation and restore;
- a per-network wallet password stored in the device-bound Keychain; and
- a privacy cover that hides wallet data in app-switcher snapshots.

The Send screen's AVFoundation scanner recognizes raw BOLT-11 invoices,
`lightning:` URIs, on-chain BIP-21 requests, and BIP-21 requests containing a
Lightning fallback. It reads QR metadata directly from the live camera and
does not capture or store photos. Scanning only fills the destination field;
the user must still review the quote and explicitly confirm the payment.

The app refreshes wallet snapshots every five seconds and also consumes the
live activity stream. The embedded Esplora wallet polls every ten seconds on
signet/testnet, every second on regtest, and every 30 seconds on mainnet. A
confirmed boarding deposit can still take up to Wavelength's 60-second
activity-reconciliation tick to move from pending to complete; pull-to-refresh
requests a new snapshot but does not bypass those daemon-owned pollers.

Lightning invoice creation asks the native mobile facade for a 20-second
request-scoped deadline. The binding marks both deadline and lifecycle
cancellation as an uncertain outcome. The app then reconciles Activity before
returning an error. If the invoice was durably created just before the response
was lost, the app recovers and displays that exact invoice; it never blindly
creates a second one after ambiguous cancellation.

After iOS has actually backgrounded the process, returning to the foreground
restarts and unlocks the same embedded wallet so its external gRPC transports
are re-dialled on the current network path. Teardown waits for any in-flight
state-creating call to return first; it is never used as a cancellation
shortcut for a payment or receive request with an uncertain outcome.

Activity follows the same lifecycle vocabulary as `wavecli activity`: issuing
an invoice is shown as a pending request, while “received” is reserved for a
completed receive. Merely allocating an on-chain address is not activity; its
deposit row appears only after Esplora observes a payment, using the observed
amount rather than the optional amount hint. Transaction IDs and on-chain
request addresses in the detail view link to the selected network's
mempool.space explorer, or to a custom configured Esplora deployment. Pending
Lightning receive details can reopen, copy, and share the original invoice as
a QR code without creating a replacement request.

Signet and testnet use the service and Esplora defaults compiled into the
Wavelength binding. Mainnet must be selected through an explicit warning. Its
backing wallet can sync from Wavelength's default mainnet Esplora URL, but a
Wavelength boarding address still needs trusted operator terms; Lightning also
needs a trusted swap endpoint. Those operations stay disabled until the user
enters the required endpoints in Settings. Wallet files and Keychain accounts
are isolated by network.

The current network can also be changed before entering the wallet—from the
first-run setup, unlock, or startup-failure screen. This prevents a persisted
network with unavailable endpoints from locking the user out of Settings.

## Build and run

The build requires `Wavewalletdk.xcframework`. The fetch script downloads a
released binding by default or builds one from a local Wavelength checkout.

```bash
# Released binding. Requires an authenticated gh CLI with repository access.
./scripts/fetch-xcframework.sh

# Or build from a local Wavelength checkout.
WAVELENGTH_DIR=/path/to/wavelength ./scripts/fetch-xcframework.sh

# Generate, build, install, and launch the app on an automatically selected
# iPhone Simulator.
make run
```

The repository Makefile selects and boots a Simulator automatically, so a
literal placeholder UDID never needs to be copied into `xcodebuild`. These
commands work from either the repository root or `ios/Sample`:

```bash
make build
make test
make run

# Optional: pin one of the devices printed by `xcrun simctl list devices`.
make test SIMULATOR_UDID=3910E643-9CEF-46AC-83B3-E531CD2A85CA
```

For a build already installed on a physical phone, attach the app and embedded
Go daemon directly to the terminal:

```bash
# Auto-select the first connected and unlocked iOS device.
make device-logs

# Optional when more than one phone is connected.
make device-logs DEVICE_UDID=9A5A428D-8182-5787-B7AC-C983FEAD67EC
```

The command terminates and relaunches the installed app with `devicectl`, then
streams both Swift output and the embedded daemon's stdout until `Ctrl-C`.
Nothing needs to be copied from the in-app developer screen. It does not build
or reinstall the app, so use Xcode (or the device build command used for that
build) first. The phone must be connected, unlocked, trusted by the Mac, and
have Developer Mode enabled. `make device` prints the auto-selected device ID.

`make run` and `make run-regtest` open and foreground Simulator.app after
booting the selected device. `make build` and `make test` stay headless. Set
`OPEN_SIMULATOR=0` for an intentionally headless application launch:

```bash
OPEN_SIMULATOR=0 make run
```

`xcodegen` is available from Homebrew: `brew install xcodegen`.
Use `xcrun simctl list devices available` to find the UDID. Simulator builds
must remain signed because the app stores its wallet secret in Keychain; an
unsigned build can launch but Keychain returns an entitlement error.

## Full regtest integration

The UI tests under `Sample/UITests` are opt-in because they need a live
operator, swap service, Esplora, and a way to fund and mine the wallet's
on-chain address. Point the app at any compatible local topology by exporting
its client-facing endpoints:

Explorer fixture seeding is not part of this workflow. A wallet integration
test only needs an address created by the app, one faucet payment, and mined
confirmations; it does not need Alice/Bob/Carol/Dave explorer history or
multiple synthetic rounds.

```bash
export WAVELENGTH_OPERATOR_ADDRESS=http://127.0.0.1:8080
export WAVELENGTH_SWAP_ADDRESS=http://127.0.0.1:8280
export WAVELENGTH_ESPLORA_URL=http://127.0.0.1:3000

make run-regtest
make test-regtest
```

The sample infers REST from an `http://` or `https://` address and gRPC from a
bare `host:port`; regtest gRPC is insecure when no certificate path is
supplied. `make run-regtest` forwards the exported values into the Simulator
without saving them in the app. Set `WAVELENGTH_UI_EXTERNAL_FUNDING=1` only for
the funding UI test. It prints `WAVELENGTH_FUND_THIS_ADDRESS=...` and waits
while another terminal funds the address and mines confirmations with the
local environment's own commands.

The mobile facade does not accept an operator credential, so the supplied
operator address must be a client-facing endpoint whose authentication policy
matches the public Wavelength client protocol. Keep administrative endpoints
separate and authenticated.

The live workflow covers wallet creation and unlock, address generation,
external funding, mined confirmations, Esplora reconciliation, activity
listing, activity-detail navigation, and Lightning invoice creation. Explorer
fixture seeding and repeated synthetic rounds are not required for these wallet
tests.

## WalletKit

`WalletKit` is the idiomatic Swift wrapper over the gomobile bindings: an
`actor`-based `WalletClient` with `async`/`throws` methods, an
Expand All @@ -14,11 +186,11 @@ daemon, creates a wallet, connects to the signet operator mailbox, and syncs to
the chain tip (`operator=connected`, `state=ready`).

> Linker note: the embedded daemon's Go networking references `res_9_*` symbols
> from **libresolv**, so the app target links `-lresolv` (set in
> `Sample/project.yml`). Without it the link fails with "Undefined symbols
> from **libresolv**, so `WalletKit` declares the `resolv` linker dependency in
> `Package.swift`. Without it the link fails with "Undefined symbols
> _res_9_ninit / _nclose / _nsearch".

## Run the sample (command line, no Xcode GUI)
## Command-line simulator control

```bash
# One time: a simulator runtime (the SDK ships with Xcode; the runtime is a
Expand All @@ -27,14 +199,13 @@ xcodebuild -downloadPlatform iOS
brew install xcodegen

# Build the bindings, generate the project, build, install, and launch.
./scripts/run-ios-sample.sh
make run

# Then drive the simulator like the Android emulator:
xcrun simctl io booted screenshot ui.png

# Or run headless (no taps): autostart boots + creates a wallet on launch.
SIMCTL_CHILD_WAVEWALLETDK_AUTOSTART=1 \
xcrun simctl launch booted engineering.lightning.wavewalletdk.sample
xcrun simctl launch booted engineering.lightning.wavelength.wallet
```

`run-ios-sample.sh` stages the xcframework, runs `xcodegen generate` on
Expand Down Expand Up @@ -81,7 +252,7 @@ class, all in a `Wavewalletdk` module. Every reference to those symbols lives in
`Bindings.swift`; if the prefix changes (the `gomobile bind -prefix` flag in
`gen_bindings.sh`), that one file is the only edit.

## Usage
## WalletKit usage

```swift
import WalletKit
Expand Down
8 changes: 8 additions & 0 deletions ios/Sample/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ROOT_DIR := ../..

.PHONY: help framework generate simulator device device-logs build test run \
run-regtest test-regtest clean

help framework generate simulator device device-logs build test run run-regtest \
test-regtest clean:
@$(MAKE) -C "$(ROOT_DIR)" --no-print-directory $@
Loading