diff --git a/.gitignore b/.gitignore index 7317edb..4da74da 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..551b501 --- /dev/null +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md index bf76389..0b15d88 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/ios/README.md b/ios/README.md index e6979b0..a8c59a7 100644 --- a/ios/README.md +++ b/ios/README.md @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/ios/Sample/Makefile b/ios/Sample/Makefile new file mode 100644 index 0000000..d357458 --- /dev/null +++ b/ios/Sample/Makefile @@ -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 $@ diff --git a/ios/Sample/Sources/ActivityViews.swift b/ios/Sample/Sources/ActivityViews.swift new file mode 100644 index 0000000..40f5030 --- /dev/null +++ b/ios/Sample/Sources/ActivityViews.swift @@ -0,0 +1,295 @@ +import SwiftUI +import WalletKit + +private enum ActivityFilter: String, CaseIterable, Identifiable { + case all = "All" + case pending = "Pending" + case received = "Received" + case sent = "Sent" + var id: String { rawValue } +} + +struct ActivityListView: View { + @EnvironmentObject private var store: WalletStore + @State private var filter: ActivityFilter = .all + @State private var search = "" + + private var entries: [Entry] { + store.activity.filter { entry in + let matchesFilter: Bool + switch filter { + case .all: matchesFilter = true + case .pending: matchesFilter = entry.status == "pending" + case .received: + matchesFilter = entry.status == "complete" && entry.isCredit + case .sent: + matchesFilter = entry.status == "complete" && !entry.isCredit + } + guard matchesFilter else { return false } + guard !search.isEmpty else { return true } + let needle = search.lowercased() + return entry.id.lowercased().contains(needle) || + entry.note.lowercased().contains(needle) || + entry.counterparty.lowercased().contains(needle) || + entry.kind.lowercased().contains(needle) + } + } + + var body: some View { + List { + Section { + Picker("Activity filter", selection: $filter) { + ForEach(ActivityFilter.allCases) { value in + Text(value.rawValue).tag(value) + } + } + .pickerStyle(.segmented) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + if entries.isEmpty { + Section { + VStack(spacing: 10) { + Image(systemName: "tray") + .font(.title2) + .foregroundStyle(.secondary) + Text(search.isEmpty ? "No matching activity" : "Nothing found") + .font(.headline) + Text("Completed and pending wallet operations appear here.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 30) + } + } else { + Section { + ForEach(entries) { entry in + NavigationLink(destination: ActivityDetailView(entry: entry)) { + ActivityRow(entry: entry) + } + .accessibilityIdentifier("activity.entry") + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Activity") + .searchable(text: $search, prompt: "Search activity") + .refreshable { await store.refresh() } + } +} + +struct ActivityDetailView: View { + @EnvironmentObject private var store: WalletStore + let entry: Entry + @State private var showingInvoiceQR = false + + private var currentEntry: Entry { + store.activity.first(where: { $0.id == entry.id }) ?? entry + } + + var body: some View { + Form { + Section { + VStack(spacing: 10) { + ActivityRailIcon(entry: currentEntry, size: 54) + Text(currentEntry.activityTitle) + .font(.headline) + Text(currentEntry.activityAmountText) + .font(.title2.bold()) + .foregroundStyle(currentEntry.activityAmountColor) + StatusLabel(status: currentEntry.status) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + + Section("Details") { + ValueRow(label: "Kind", value: currentEntry.kind.replacingOccurrences(of: "_", with: " ").capitalized) + ValueRow(label: "Rail", value: currentEntry.isLightningActivity ? "Lightning" : "On-chain") + ValueRow(label: "Direction", value: currentEntry.activityDirectionLabel) + if currentEntry.feeSat > 0 { + ValueRow(label: "Fee", value: WalletFormatting.sats(currentEntry.feeSat)) + } + if let created = WalletFormatting.date(currentEntry.createdAt) { + ValueRow(label: "Created", value: created) + } + if let updated = WalletFormatting.date(currentEntry.updatedAt), updated != WalletFormatting.date(currentEntry.createdAt) { + ValueRow(label: "Updated", value: updated) + } + if !currentEntry.note.isEmpty { ValueRow(label: "Note", value: currentEntry.note) } + if !currentEntry.counterparty.isEmpty { + ValueRow(label: "Counterparty", value: currentEntry.counterparty, copyable: true) + } + } + + if let progress = currentEntry.progress { + Section("Progress") { + if !progress.phaseLabel.isEmpty || !progress.phase.isEmpty { + ValueRow(label: "Phase", value: (progress.phaseLabel.isEmpty ? progress.phase : progress.phaseLabel).replacingOccurrences(of: "_", with: " ").capitalized) + } + if progress.confirmationHeight > 0 { + ValueRow(label: "Confirmation height", value: "\(progress.confirmationHeight)") + } + if !progress.paymentHash.isEmpty { + ValueRow(label: "Payment hash", value: progress.paymentHash, copyable: true) + } + if !progress.txid.isEmpty { + ValueRow(label: "Transaction ID", value: progress.txid, copyable: true) + if let url = store.transactionExplorerURL(txid: progress.txid) { + Link("View in block explorer", destination: url) + } + } + if !progress.vtxoOutpoint.isEmpty { + ValueRow(label: "VTXO outpoint", value: progress.vtxoOutpoint, copyable: true) + } + if !progress.preimage.isEmpty { + ValueRow(label: "Payment preimage", value: progress.preimage, copyable: true) + } + } + } + + if let request = currentEntry.request { + Section("Original request") { + ValueRow(label: "Rail", value: request.type.capitalized) + if !request.lightningInvoice.isEmpty { + ValueRow(label: "Lightning invoice", value: request.lightningInvoice, copyable: true) + if currentEntry.kind == "receive", + currentEntry.status == "pending" { + Button { + showingInvoiceQR = true + } label: { + Label("Show QR Code", systemImage: "qrcode") + } + .accessibilityIdentifier("activity.invoice.qr") + } + } + if !request.paymentHash.isEmpty, + request.paymentHash != currentEntry.progress?.paymentHash { + ValueRow(label: "Payment hash", value: request.paymentHash, copyable: true) + } + if !request.onchainAddress.isEmpty { + if let url = store.addressExplorerURL(address: request.onchainAddress) { + ExplorerValueRow( + label: "Bitcoin address", + value: request.onchainAddress, + destination: url + ) + } else { + ValueRow(label: "Bitcoin address", value: request.onchainAddress, copyable: true) + } + } + if !request.arkAddress.isEmpty { + ValueRow(label: "Ark address", value: request.arkAddress, copyable: true) + } + } + } + + if currentEntry.status == "failed" || !(currentEntry.failureReason ?? "").isEmpty { + Section("Failure") { + if let code = currentEntry.failureCode, !code.isEmpty { + ValueRow(label: "Code", value: code.replacingOccurrences(of: "_", with: " ").capitalized) + } + if let reason = currentEntry.failureReason, !reason.isEmpty { + Text(reason).foregroundStyle(.red) + } + } + } + + Section("Reference") { + ValueRow(label: "Activity ID", value: currentEntry.id, copyable: true) + } + } + .navigationTitle("Activity Details") + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("activity.detail") + .sheet(isPresented: $showingInvoiceQR) { + LightningInvoiceQRCodeSheet( + invoice: currentEntry.request?.lightningInvoice ?? "", + amountSat: currentEntry.amountSat + ) + } + } + +} + +private struct LightningInvoiceQRCodeSheet: View { + @Environment(\.presentationMode) private var presentationMode + let invoice: String + let amountSat: Int64 + @State private var showingShare = false + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 22) { + QRCodeView(value: invoice) + .frame(width: 270, height: 270) + .padding(14) + .background(Color.white, in: RoundedRectangle(cornerRadius: 20)) + .accessibilityLabel("Pending Lightning invoice QR code") + + VStack(spacing: 6) { + Text("Pending Lightning invoice") + .font(.headline) + if amountSat > 0 { + Text(WalletFormatting.sats(amountSat)) + .font(.title3.bold()) + } + Text("This is the original invoice. Sharing it does not create a new payment request.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + Text(invoice) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + .privacySensitive() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: 12) + ) + + HStack(spacing: 14) { + Button { + UIPasteboard.general.string = invoice + } label: { + Label("Copy", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { + showingShare = true + } label: { + Label("Share", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + } + .padding(24) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Receive Payment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + .sheet(isPresented: $showingShare) { + ShareSheet(items: [invoice]) + } + } + .navigationViewStyle(.stack) + } +} diff --git a/ios/Sample/Sources/Components.swift b/ios/Sample/Sources/Components.swift new file mode 100644 index 0000000..b52ed8d --- /dev/null +++ b/ios/Sample/Sources/Components.swift @@ -0,0 +1,424 @@ +import SwiftUI +import WalletKit + +struct WavelengthMark: View { + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [.orange, Color(red: 1, green: 0.42, blue: 0.12)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Image(systemName: "wave.3.right") + .font(.system(size: size * 0.43, weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + .shadow(color: .orange.opacity(0.25), radius: 14, y: 8) + .accessibilityHidden(true) + } +} + +struct NetworkBadge: View { + let network: WalletNetwork + + var body: some View { + HStack(spacing: 6) { + Circle().fill(network.color).frame(width: 7, height: 7) + Text(network.title) + } + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(network.color.opacity(0.12), in: Capsule()) + .foregroundStyle(network.color) + .accessibilityLabel("Bitcoin network: \(network.title)") + } +} + +/// Opens the network chooser from screens that cannot reach the Settings tab, +/// such as first-run setup, unlock, and daemon-start failure states. +struct NetworkSelectionButton: View { + @EnvironmentObject private var store: WalletStore + @State private var showingNetworks = false + + var body: some View { + Button { + showingNetworks = true + } label: { + HStack(spacing: 7) { + NetworkBadge(network: store.network) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Change Bitcoin network. Current network: \(store.network.title)") + .sheet(isPresented: $showingNetworks) { + NetworkSelectionView() + .environmentObject(store) + } + } +} + +private struct NetworkSelectionView: View { + @EnvironmentObject private var store: WalletStore + @Environment(\.presentationMode) private var presentationMode + @State private var proposedMainnet = false + @State private var isSwitching = false + + var body: some View { + NavigationView { + List { + Section { + ForEach(WalletNetwork.selectableNetworks, id: \.rawValue) { network in + Button { + select(network) + } label: { + HStack(spacing: 12) { + Circle() + .fill(network.color) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(network.title) + .foregroundStyle(.primary) + Text(network.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if store.network == network { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(network.color) + } + } + } + .disabled(isSwitching || store.network == network) + } + } header: { + Text("Bitcoin network") + } footer: { + Text("Each network has an isolated wallet database, balance, activity history, and device encryption key. Switching never moves funds between networks.") + } + + if isSwitching { + Section { + HStack(spacing: 12) { + ProgressView() + Text("Starting \(store.network.title)…") + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Choose Network") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .disabled(isSwitching) + } + } + .confirmationDialog( + "Use real bitcoin on Mainnet?", + isPresented: $proposedMainnet, + titleVisibility: .visible + ) { + Button("Switch to Mainnet", role: .destructive) { + switchTo(.mainnet) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Mainnet uses a separate wallet and real funds. Off-chain operations remain unavailable until you configure trusted services.") + } + } + .navigationViewStyle(.stack) + } + + private func select(_ network: WalletNetwork) { + guard network != store.network else { return } + if network == .mainnet { + proposedMainnet = true + } else { + switchTo(network) + } + } + + private func switchTo(_ network: WalletNetwork) { + isSwitching = true + Task { + await store.switchNetwork(to: network) + presentationMode.wrappedValue.dismiss() + } + } +} + +struct ActivityRow: View { + let entry: Entry + + var body: some View { + HStack(alignment: .top, spacing: 13) { + ActivityRailIcon(entry: entry) + + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(entry.activityTitle) + .font(.body.weight(.medium)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + + Spacer(minLength: 4) + + Text(entry.activityAmountText) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(entry.activityAmountColor) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + + Spacer(minLength: 4) + + StatusLabel(status: entry.status) + .fixedSize() + } + } + } + .padding(.vertical, 5) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } + + private var detail: String { + let context: String + if let date = WalletFormatting.date(entry.createdAt) { + context = date + } else if !entry.note.isEmpty { + context = entry.note + } else if !entry.counterparty.isEmpty { + context = WalletFormatting.shortened(entry.counterparty) + } else { + context = WalletFormatting.shortened(entry.id) + } + return "\(entry.activityDirectionLabel) · \(context)" + } +} + +/// The large glyph identifies the payment rail; the small badge identifies +/// direction. That keeps Lightning/on-chain and incoming/outgoing as two +/// independent, consistently represented pieces of information. +struct ActivityRailIcon: View { + let entry: Entry + var size: CGFloat = 42 + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Circle() + .fill(entry.activityColor.opacity(0.12)) + .frame(width: size, height: size) + + Image(systemName: entry.isLightningActivity ? "bolt.fill" : "bitcoinsign") + .font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(entry.activityColor) + .frame(width: size, height: size) + + ZStack { + Circle() + .fill(entry.activityColor) + Image(systemName: entry.isCredit ? "arrow.down.left" : "arrow.up.right") + .font(.system(size: size * 0.20, weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: size * 0.42, height: size * 0.42) + .overlay(Circle().stroke(Color(.systemBackground), lineWidth: 2)) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +extension Entry { + var activityColor: Color { + switch status { + case "failed": return .red + case "pending": return .orange + default: return isCredit ? .green : .blue + } + } + + var isCredit: Bool { kind == "receive" || kind == "deposit" } + + var isLightningActivity: Bool { + if request?.type.lowercased() == "lightning" { return true } + if request?.type.lowercased() == "onchain" { return false } + if kind == "receive" { return true } + if kind == "deposit" || kind == "exit" { return false } + return !(progress?.paymentHash ?? "").isEmpty + } + + var activityDirectionLabel: String { + if isAwaitingIncomingPayment { return "Incoming request" } + return isCredit ? "Incoming" : "Outgoing" + } + + /// A newly issued invoice/address is an invitation to pay, not evidence + /// that money was received. Keep requested amounts visually distinct until + /// Wavelength reports payment detection or a later lifecycle phase. + var isAwaitingIncomingPayment: Bool { + guard isCredit, status == "pending" else { return false } + let phase = progress?.phase ?? "" + let label = progress?.phaseLabel ?? "" + let requestOnlyPhases = [ + "request_created", "waiting_for_payment", "address_issued", + ] + let lifecycle = label.isEmpty ? phase : label + return lifecycle.isEmpty || requestOnlyPhases.contains(lifecycle) + } + + var activityTitle: String { + switch kind { + case "send": + if status == "failed" { + return isLightningActivity ? "Lightning send failed" : "On-chain send failed" + } + if isLightningActivity { + return status == "complete" ? "Lightning sent" : "Lightning send" + } + return status == "complete" ? "On-chain sent" : "On-chain send" + case "receive": + if status == "complete" { return "Lightning received" } + if status == "failed" { return "Lightning receive failed" } + return isAwaitingIncomingPayment ? "Lightning invoice" : "Lightning receive" + case "deposit": + if status == "failed" { return "On-chain deposit failed" } + return isAwaitingIncomingPayment ? "On-chain address" : "On-chain deposit" + case "exit": + return "On-chain exit" + default: + return kind.replacingOccurrences(of: "_", with: " ").capitalized + } + } + + var activityAmountText: String { + if isAwaitingIncomingPayment { + guard amountSat > 0 else { return "Waiting for payment" } + let suffix = kind == "receive" ? "requested" : "expected" + return "\(WalletFormatting.sats(amountSat)) \(suffix)" + } + if isCredit, status == "pending" { + return WalletFormatting.sats(amountSat) + } + let signedAmount = isCredit ? abs(amountSat) : -abs(amountSat) + return WalletFormatting.sats(signedAmount, signed: true) + } + + var activityAmountColor: Color { + if isAwaitingIncomingPayment { return .secondary } + if isCredit, status == "pending" { return .primary } + return isCredit ? .green : .primary + } +} + +struct StatusLabel: View { + let status: String + + var body: some View { + Text(status.capitalized) + .font(.caption2.weight(.medium)) + .foregroundStyle(color) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(color.opacity(0.12), in: Capsule()) + } + + private var color: Color { + switch status { + case "complete": return .green + case "failed": return .red + default: return .orange + } + } +} + +struct ExplorerValueRow: View { + let label: String + let value: String + let destination: URL + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + CopyButton(value: value) + } + Link(destination: destination) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(value) + .font(.system(.callout, design: .monospaced)) + .multilineTextAlignment(.leading) + Spacer(minLength: 4) + Image(systemName: "arrow.up.right.square") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .accessibilityElement(children: .contain) + } +} + +struct CopyButton: View { + let value: String + @State private var copied = false + + var body: some View { + Button { + UIPasteboard.general.string = value + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { copied = false } + } label: { + Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") + } + .font(.caption.weight(.medium)) + .accessibilityLabel(copied ? "Copied" : "Copy to clipboard") + } +} + +struct ValueRow: View { + let label: String + let value: String + var copyable = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + if copyable { CopyButton(value: value) } + } + Text(value) + .font(.system(.callout, design: copyable ? .monospaced : .default)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + } +} diff --git a/ios/Sample/Sources/ContentView.swift b/ios/Sample/Sources/ContentView.swift index ed3b70f..59ce3e7 100644 --- a/ios/Sample/Sources/ContentView.swift +++ b/ios/Sample/Sources/ContentView.swift @@ -1,155 +1,114 @@ import SwiftUI -import WalletKit -// ContentView drives the embedded wallet through WalletKit's WalletClient -// (async/throws + AsyncThrowingStream). It boots on signet, creates a wallet, -// polls readiness as the chain syncs, and streams live activity. It mirrors the -// Android sample so the two platforms exercise the same wrapper surface. struct ContentView: View { - @State private var client = WalletClient() - @State private var log = "Tap Start to boot the embedded wallet.\n" - @State private var running = false - @State private var busy = false - @State private var walletReady = false - - private var dataDir: String { - FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - .first!.appendingPathComponent("wavewalletdk").path - } + @Environment(\.scenePhase) private var scenePhase + @StateObject private var store = WalletStore() + @State private var enteredBackground = false var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("wavewalletdk signet demo").font(.system(.headline, design: .monospaced)) - - HStack { - Button("Start") { start() }.disabled(running || busy) - Button("Create Wallet") { createWallet() }.disabled(!running || walletReady) - Button("Balance") { showBalance() }.disabled(!running) - Button("Stop") { stop() }.disabled(!running) - } - .buttonStyle(.borderedProminent) + ZStack { + content + .environmentObject(store) - ScrollView { - Text(log) - .font(.system(.caption, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) + if store.isWorking { + Color.black.opacity(0.18).ignoresSafeArea() + ProgressView() + .padding(22) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) } } - .padding() .task { - // Headless driver for CLI / CI runs: set WAVEWALLETDK_AUTOSTART to boot - // and create a wallet without tapping (the simulator has no CLI tap). - if ProcessInfo.processInfo.environment["WAVEWALLETDK_AUTOSTART"] != nil { - await autoStart() + if store.phase == .idle { + await store.start() + #if DEBUG + if ProcessInfo.processInfo.environment["WAVELENGTH_AUTOCREATE"] == "1", + store.phase == .needsSetup { + await store.createWallet(showBackup: false) + } + #endif } } - } - - private func autoStart() async { - busy = true - append("Auto-start (signet)…") - do { - try await client.start(.signet(dataDir: dataDir)) - running = true - append("gRPC serving. Creating wallet…") - pollSync() - let res = try await client.createWallet( - walletPassword: Data("wavelength-mobile-demo-password".utf8) - ) - walletReady = true - append("wallet created; identity=\(res.identityPubKey.prefix(16))…") - streamActivity() - } catch { - append("autostart failed: \(error)") - } - busy = false - } - - @MainActor private func append(_ line: String) { log += line + "\n" } - - private func start() { - busy = true - append("Starting embedded daemon (signet)…") - Task { - do { - try await client.start(.signet(dataDir: dataDir)) - running = true - append("gRPC serving. Create a wallet to sync.") - pollSync() - } catch { - append("Start failed: \(error)") + .onChange(of: scenePhase) { phase in + switch phase { + case .background: + enteredBackground = true + case .active where enteredBackground: + enteredBackground = false + Task { await store.resumeAfterBackground() } + default: + break } - busy = false } - } - - private func createWallet() { - append("Creating wallet…") - Task { - do { - let res = try await client.createWallet( - walletPassword: Data("wavelength-mobile-demo-password".utf8) - ) - walletReady = true - append("wallet created; identity=\(res.identityPubKey.prefix(16))…") - append("seed words: \(res.mnemonic.count); now syncing from Esplora.") - streamActivity() - } catch { - append("createWallet failed: \(error)") - } + .alert("Wavelength", isPresented: alertIsPresented) { + Button("OK", role: .cancel) { store.alertMessage = nil } + } message: { + Text(store.alertMessage ?? "") } } - private func showBalance() { - Task { - do { - let b = try await client.balance() - append("balance: confirmed=\(b.confirmedSat) pendingIn=\(b.pendingInSat)") - } catch { - append("balance failed: \(error)") + @ViewBuilder + private var content: some View { + if let mnemonic = store.pendingMnemonic { + SeedBackupView(words: mnemonic) + } else { + switch store.phase { + case .idle, .starting: + LaunchView(message: "Starting your wallet…") + case .needsSetup: + WalletSetupView() + case .needsUnlock: + UnlockWalletView() + case .syncing, .ready: + WalletShellView() + case .failed(let message): + FailureView(message: message) { store.retry() } } } } - private func stop() { - Task { - do { - try await client.stop() - running = false - walletReady = false - append("Stopped.") - } catch { - append("stop failed: \(error)") - } - } + private var alertIsPresented: Binding { + Binding( + get: { store.alertMessage != nil }, + set: { if !$0 { store.alertMessage = nil } } + ) } +} - // Poll readiness every 5s while running so the block height and operator - // connection are visible as the wallet syncs. - private func pollSync() { - Task { - while running { - try? await Task.sleep(nanoseconds: 5_000_000_000) - if let info = try? await client.getInfo() { - append( - "sync: height=\(info.blockHeight) state=\(info.walletState) " + - "operator=\(info.serverConnected ? "connected" : "…")" - ) - } - } +private struct LaunchView: View { + @EnvironmentObject private var store: WalletStore + let message: String + + var body: some View { + VStack(spacing: 24) { + WavelengthMark(size: 74) + ProgressView(message) + .tint(.orange) + NetworkSelectionButton() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) } +} - // Stream live activity entries through the AsyncThrowingStream. - private func streamActivity() { - Task { - do { - for try await e in client.activity(includeExisting: true) { - append("activity: \(e.kind) \(e.amountSat)sat \(e.status)") - } - } catch { - append("activity stream ended: \(error)") - } +private struct FailureView: View { + let message: String + let retry: () -> Void + + var body: some View { + VStack(spacing: 18) { + WavelengthMark(size: 68) + Text("Wallet unavailable") + .font(.title2.bold()) + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + NetworkSelectionButton() + Button("Try Again", action: retry) + .buttonStyle(.borderedProminent) } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) } } diff --git a/ios/Sample/Sources/OnboardingViews.swift b/ios/Sample/Sources/OnboardingViews.swift new file mode 100644 index 0000000..0622f12 --- /dev/null +++ b/ios/Sample/Sources/OnboardingViews.swift @@ -0,0 +1,187 @@ +import SwiftUI + +struct WalletSetupView: View { + @EnvironmentObject private var store: WalletStore + @State private var showingRestore = false + + var body: some View { + NavigationView { + VStack(spacing: 24) { + Spacer() + WavelengthMark(size: 86) + VStack(spacing: 8) { + Text("Your bitcoin, in your hands") + .font(.title2.bold()) + Text("A self-custodial Wavelength wallet, synced with Esplora and secured on this device.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + NetworkSelectionButton() + + Spacer() + + VStack(spacing: 12) { + Button { + Task { await store.createWallet() } + } label: { + Text("Create New Wallet") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button("Restore from Recovery Words") { + showingRestore = true + } + .controlSize(.large) + } + + Text("Wavelength never sends your seed or wallet password off this device.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(28) + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .sheet(isPresented: $showingRestore) { + RestoreWalletView() + .environmentObject(store) + } + } + .navigationViewStyle(.stack) + } +} + +private struct RestoreWalletView: View { + @EnvironmentObject private var store: WalletStore + @Environment(\.presentationMode) private var presentationMode + @State private var words = "" + + private var mnemonic: [String] { + words.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init) + } + + var body: some View { + NavigationView { + Form { + Section { + TextEditor(text: $words) + .frame(minHeight: 150) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .privacySensitive() + } header: { + Text("Recovery words") + } footer: { + Text("Enter the words in order, separated by spaces. The wallet will recover addresses using the selected \(store.network.title) network.") + } + + Section { + Button("Restore Wallet") { + Task { + await store.createWallet(mnemonic: mnemonic, showBackup: false) + if store.phase.isWalletAvailable { + presentationMode.wrappedValue.dismiss() + } + } + } + .disabled(mnemonic.count < 12 || store.isWorking) + } + } + .navigationTitle("Restore Wallet") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { presentationMode.wrappedValue.dismiss() } + } + } + } + } +} + +struct UnlockWalletView: View { + @EnvironmentObject private var store: WalletStore + @State private var password = "" + + var body: some View { + VStack(spacing: 22) { + WavelengthMark(size: 72) + Text("Unlock Wallet").font(.title2.bold()) + Text("This wallet database was found, but its device key is unavailable. Enter the password originally used to create it.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + NetworkSelectionButton() + SecureField("Wallet password", text: $password) + .textContentType(.password) + .padding(13) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + Button("Unlock") { + Task { await store.unlockWallet(password: password) } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(password.isEmpty || store.isWorking) + } + .padding(30) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + } +} + +struct SeedBackupView: View { + @EnvironmentObject private var store: WalletStore + let words: [String] + @State private var acknowledged = false + + private let columns = [GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Label("Write these words down", systemImage: "exclamationmark.shield.fill") + .font(.title2.bold()) + .foregroundStyle(.orange) + + Text("They are the only backup for your wallet. Keep them offline and never share them with anyone.") + .foregroundStyle(.secondary) + + LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { + ForEach(Array(words.enumerated()), id: \.offset) { index, word in + HStack(spacing: 8) { + Text("\(index + 1)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 22, alignment: .trailing) + Text(word) + .font(.body.monospaced()) + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 10)) + } + } + .privacySensitive() + + Toggle("I saved these recovery words", isOn: $acknowledged) + .font(.body.weight(.medium)) + + Button { + store.acknowledgeSeedBackup() + } label: { + Text("Continue to Wallet").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!acknowledged) + } + .padding(22) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Wallet Backup") + .navigationBarTitleDisplayMode(.inline) + } + .navigationViewStyle(.stack) + } +} diff --git a/ios/Sample/Sources/QRCodeScannerView.swift b/ios/Sample/Sources/QRCodeScannerView.swift new file mode 100644 index 0000000..30b3e95 --- /dev/null +++ b/ios/Sample/Sources/QRCodeScannerView.swift @@ -0,0 +1,278 @@ +import AVFoundation +import SwiftUI +import UIKit +import WalletKit + +struct PaymentQRCodeScannerView: View { + @Environment(\.presentationMode) private var presentationMode + @State private var authorization = AVCaptureDevice.authorizationStatus(for: .video) + @State private var cameraError: String? + + let onScan: (String) -> Void + + var body: some View { + NavigationView { + ZStack { + Color.black.ignoresSafeArea() + + scannerContent + } + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .foregroundStyle(.white) + } + } + .task { await requestCameraAccessIfNeeded() } + } + } + + @ViewBuilder + private var scannerContent: some View { + switch authorization { + case .authorized: + if let cameraError { + cameraUnavailable(message: cameraError) + } else { + ZStack { + QRCodeCameraPreview( + onCode: accept, + onError: { cameraError = $0.localizedDescription } + ) + .ignoresSafeArea() + + VStack(spacing: 22) { + Spacer() + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(.white, lineWidth: 3) + .frame(width: 260, height: 260) + .shadow(color: .black.opacity(0.45), radius: 5) + .accessibilityHidden(true) + Text("Position a Lightning invoice or Bitcoin payment QR code inside the frame.") + .font(.subheadline.weight(.medium)) + .multilineTextAlignment(.center) + .foregroundStyle(.white) + .padding(.horizontal, 30) + .padding(.vertical, 12) + .background(.black.opacity(0.65), in: Capsule()) + .padding(.horizontal, 24) + Spacer() + } + } + } + + case .notDetermined: + ProgressView("Requesting camera access…") + .tint(.white) + .foregroundStyle(.white) + + case .denied, .restricted: + VStack(spacing: 18) { + Image(systemName: "camera.fill") + .font(.system(size: 42)) + Text("Camera Access Needed") + .font(.title2.bold()) + Text("Allow camera access to scan payment QR codes. You can still cancel and paste an invoice instead.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + if authorization == .denied { + Button("Open Settings") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + .buttonStyle(.borderedProminent) + } + } + .foregroundStyle(.white) + .padding(32) + + @unknown default: + cameraUnavailable(message: "Camera authorization is unavailable.") + } + } + + private func cameraUnavailable(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "camera.metering.unknown") + .font(.system(size: 42)) + Text("Camera Unavailable") + .font(.title2.bold()) + Text(message) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + Text("Cancel and use Paste from Clipboard to enter the payment request.") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .foregroundStyle(.white) + .padding(32) + } + + @MainActor + private func requestCameraAccessIfNeeded() async { + guard authorization == .notDetermined else { return } + let granted = await AVCaptureDevice.requestAccess(for: .video) + authorization = granted ? .authorized : .denied + } + + private func accept(_ payload: String) { + let destination = PaymentRequestParser.normalizedDestination(from: payload) + guard !destination.isEmpty else { + cameraError = "The QR code did not contain a payment request." + return + } + + UINotificationFeedbackGenerator().notificationOccurred(.success) + onScan(destination) + presentationMode.wrappedValue.dismiss() + } +} + +private struct QRCodeCameraPreview: UIViewControllerRepresentable { + let onCode: (String) -> Void + let onError: (Error) -> Void + + func makeUIViewController(context: Context) -> QRCodeScannerViewController { + QRCodeScannerViewController(onCode: onCode, onError: onError) + } + + func updateUIViewController(_ uiViewController: QRCodeScannerViewController, context: Context) {} +} + +private final class QRCodeScannerViewController: UIViewController, + AVCaptureMetadataOutputObjectsDelegate { + + private let captureSession = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "engineering.lightning.wavelength.qr-camera") + private let onCode: (String) -> Void + private let onError: (Error) -> Void + private var previewLayer: AVCaptureVideoPreviewLayer? + private var isConfigured = false + private var didScan = false + + init(onCode: @escaping (String) -> Void, onError: @escaping (Error) -> Void) { + self.onCode = onCode + self.onError = onError + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + configureSession() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard isConfigured else { return } + sessionQueue.async { [captureSession] in + if !captureSession.isRunning { + captureSession.startRunning() + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + sessionQueue.async { [captureSession] in + if captureSession.isRunning { + captureSession.stopRunning() + } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + if let connection = previewLayer?.connection, + connection.isVideoOrientationSupported { + connection.videoOrientation = .portrait + } + } + + private func configureSession() { + guard let camera = AVCaptureDevice.default(for: .video) else { + fail(QRCodeScannerError.cameraUnavailable) + return + } + + do { + let input = try AVCaptureDeviceInput(device: camera) + guard captureSession.canAddInput(input) else { + fail(QRCodeScannerError.inputUnavailable) + return + } + captureSession.addInput(input) + + let output = AVCaptureMetadataOutput() + guard captureSession.canAddOutput(output) else { + fail(QRCodeScannerError.outputUnavailable) + return + } + captureSession.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + + let preview = AVCaptureVideoPreviewLayer(session: captureSession) + preview.videoGravity = .resizeAspectFill + preview.frame = view.bounds + view.layer.addSublayer(preview) + previewLayer = preview + isConfigured = true + } catch { + fail(error) + } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !didScan, + let code = metadataObjects + .compactMap({ $0 as? AVMetadataMachineReadableCodeObject }) + .first(where: { $0.type == .qr })?.stringValue else { + return + } + + didScan = true + sessionQueue.async { [captureSession] in + if captureSession.isRunning { + captureSession.stopRunning() + } + } + onCode(code) + } + + private func fail(_ error: Error) { + DispatchQueue.main.async { [onError] in onError(error) } + } +} + +private enum QRCodeScannerError: LocalizedError { + case cameraUnavailable + case inputUnavailable + case outputUnavailable + + var errorDescription: String? { + switch self { + case .cameraUnavailable: + return "This device does not provide a camera. The iOS Simulator usually requires clipboard input." + case .inputUnavailable: + return "The camera could not be connected to the scanner." + case .outputUnavailable: + return "QR-code recognition is unavailable on this device." + } + } +} diff --git a/ios/Sample/Sources/ReceiveView.swift b/ios/Sample/Sources/ReceiveView.swift new file mode 100644 index 0000000..b77d637 --- /dev/null +++ b/ios/Sample/Sources/ReceiveView.swift @@ -0,0 +1,250 @@ +import CoreImage +import CoreImage.CIFilterBuiltins +import SwiftUI +import UIKit + +private enum ReceiveRail: String, CaseIterable, Identifiable { + case lightning = "Lightning" + case onchain = "On-chain" + var id: String { rawValue } +} + +struct ReceiveView: View { + @EnvironmentObject private var store: WalletStore + @Environment(\.presentationMode) private var presentationMode + @State private var rail: ReceiveRail = .lightning + @State private var amount = "" + @State private var memo = "" + @State private var request = "" + @State private var isLoading = false + @State private var isTakingLonger = false + @State private var errorMessage: String? + @State private var showingShare = false + + private var amountSat: Int64 { Int64(amount) ?? 0 } + + var body: some View { + NavigationView { + Form { + if request.isEmpty { + requestForm + } else { + paymentRequest + } + } + .navigationTitle("Receive Bitcoin") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(request.isEmpty ? "Cancel" : "Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + .alert("Couldn’t Create Request", isPresented: errorIsPresented) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .sheet(isPresented: $showingShare) { + ShareSheet(items: [request]) + } + .onAppear { + if !store.offchainAvailable { rail = .onchain } + } + } + } + + @ViewBuilder + private var requestForm: some View { + Section { + Picker("Receive using", selection: $rail) { + ForEach(ReceiveRail.allCases) { option in + Text(option.rawValue) + .tag(option) + .disabled(option == .lightning && !store.offchainAvailable) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("receive.rail") + } footer: { + if !store.operatorAvailable { + Text("\(store.network.title) receive requires a trusted Ark operator endpoint in Settings. Esplora syncs the chain, but it cannot create a Wavelength boarding address by itself.") + } else if !store.offchainAvailable { + Text("\(store.network.title) Lightning additionally requires a swap endpoint in Settings. On-chain boarding receive remains available.") + } + } + + Section("Amount") { + HStack { + TextField(rail == .lightning ? "Required" : "Optional hint", text: $amount) + .keyboardType(.numberPad) + .accessibilityIdentifier("receive.amount") + Text("sats").foregroundStyle(.secondary) + } + } + + if rail == .lightning { + Section("Optional") { + TextField("Memo", text: $memo) + } + } + + Section { + Button { + createRequest() + } label: { + HStack { + Spacer() + if isLoading { ProgressView().padding(.trailing, 6) } + if isTakingLonger { + Text("Still connecting…") + } else { + Text(rail == .lightning ? "Create Invoice" : "Create Address") + } + Spacer() + } + } + .disabled(!canCreate || isLoading) + .accessibilityIdentifier("receive.create") + } footer: { + Text(rail == .lightning + ? "Creates a BOLT 11 invoice payable into your Wavelength wallet." + : "Creates a fresh boarding address tracked by the wallet’s Esplora-based chain backend.") + } + } + + private var paymentRequest: some View { + Group { + Section { + VStack(spacing: 18) { + QRCodeView(value: request) + .frame(width: 230, height: 230) + .padding(12) + .background(Color.white, in: RoundedRectangle(cornerRadius: 18)) + .accessibilityLabel("Payment request QR code") + + Text(rail == .lightning ? "Lightning invoice" : "Bitcoin address") + .font(.headline) + if amountSat > 0 { + Text(WalletFormatting.sats(amountSat)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + + Section { + Text(request) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .privacySensitive() + .accessibilityIdentifier("receive.request") + Button { + UIPasteboard.general.string = request + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .accessibilityIdentifier("receive.copy") + Button { + showingShare = true + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } + + Section { + Button("Create Another Request") { request = "" } + } + } + } + + private var canCreate: Bool { + store.isReady && store.operatorAvailable && + (rail == .onchain || (amountSat > 0 && store.offchainAvailable)) + } + + private func createRequest() { + isLoading = true + isTakingLonger = false + let slowNotice = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard !Task.isCancelled, isLoading, rail == .lightning else { return } + isTakingLonger = true + } + Task { + defer { + slowNotice.cancel() + isLoading = false + isTakingLonger = false + } + do { + switch rail { + case .lightning: + let result = try await store.receiveLightning( + amountSat: amountSat, + memo: memo.trimmingCharacters(in: .whitespacesAndNewlines) + ) + request = result.invoice + case .onchain: + let result = try await store.newDepositAddress(amountHintSat: amountSat) + request = result.address + } + } catch { + errorMessage = error.walletMessage + } + } + } + + private var errorIsPresented: Binding { + Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + } +} + +struct QRCodeView: View { + let value: String + + var body: some View { + if let image = QRCodeGenerator.image(for: value) { + Image(uiImage: image) + .resizable() + .interpolation(.none) + .scaledToFit() + } else { + Image(systemName: "qrcode") + .resizable() + .scaledToFit() + .foregroundStyle(.black) + } + } +} + +private enum QRCodeGenerator { + static func image(for value: String) -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(value.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let transformed = output.transformed(by: CGAffineTransform(scaleX: 12, y: 12)) + let context = CIContext(options: nil) + guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { + return nil + } + return UIImage(cgImage: cgImage) + } +} + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/ios/Sample/Sources/SendView.swift b/ios/Sample/Sources/SendView.swift new file mode 100644 index 0000000..e26899f --- /dev/null +++ b/ios/Sample/Sources/SendView.swift @@ -0,0 +1,277 @@ +import SwiftUI +import WalletKit + +struct SendView: View { + @EnvironmentObject private var store: WalletStore + @Environment(\.presentationMode) private var presentationMode + + @State private var destination = "" + @State private var amount = "" + @State private var note = "" + @State private var maxFee = "" + @State private var quote: PrepareSendResult? + @State private var sentResult: SendResult? + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showsScanner = false + + private var normalizedDestination: String { + PaymentRequestParser.normalizedDestination(from: destination) + } + + private var isLightning: Bool { normalizedDestination.lowercased().hasPrefix("ln") } + private var amountSat: Int64 { Int64(amount) ?? 0 } + private var maxFeeSat: Int64 { Int64(maxFee) ?? 0 } + + var body: some View { + NavigationView { + Form { + if let sentResult { + successSection(sentResult) + } else if let quote { + reviewSections(quote) + } else { + inputSections + } + } + .navigationTitle(sentResult == nil ? "Send Bitcoin" : "Payment Sent") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(sentResult == nil ? "Cancel" : "Done") { + presentationMode.wrappedValue.dismiss() + } + } + } + .alert("Couldn’t Send", isPresented: errorIsPresented) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .onChange(of: destination) { _ in quote = nil } + .onChange(of: amount) { _ in quote = nil } + .onChange(of: maxFee) { _ in quote = nil } + .sheet(isPresented: $showsScanner) { + PaymentQRCodeScannerView { scannedDestination in + destination = scannedDestination + } + } + } + } + + @ViewBuilder + private var inputSections: some View { + Section { + ZStack(alignment: .topLeading) { + if destination.isEmpty { + Text("Lightning invoice or Bitcoin address") + .foregroundStyle(Color(.placeholderText)) + .padding(.top, 8) + .padding(.leading, 5) + } + TextEditor(text: $destination) + .frame(minHeight: 90) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + HStack { + Button { + showsScanner = true + } label: { + Label("Scan QR Code", systemImage: "viewfinder") + } + .accessibilityIdentifier("send.scan") + + Spacer() + + Button { + if let value = UIPasteboard.general.string { destination = value } + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + } + } + } header: { + Text("Pay to") + } footer: { + Text(destinationHint) + } + + if !isLightning { + Section("Amount") { + HStack { + TextField("0", text: $amount) + .keyboardType(.numberPad) + Text("sats").foregroundStyle(.secondary) + } + } + } + + Section("Optional") { + TextField("Note", text: $note) + if isLightning { + HStack { + TextField("Maximum fee", text: $maxFee) + .keyboardType(.numberPad) + Text("sats").foregroundStyle(.secondary) + } + } + } + + if !store.offchainAvailable { + Section { + Label( + "\(store.network.title) sending requires operator and swap endpoints in Settings.", + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + } + } + + Section { + Button { + prepare() + } label: { + HStack { + Spacer() + if isLoading { ProgressView().padding(.trailing, 6) } + Text("Review Payment") + Spacer() + } + } + .disabled(!canPrepare || isLoading) + } footer: { + Text("Preparing validates the destination and quotes fees. It does not move funds.") + } + } + + @ViewBuilder + private func reviewSections(_ quote: PrepareSendResult) -> some View { + Section { + VStack(spacing: 12) { + Image(systemName: isLightning ? "bolt.fill" : "bitcoinsign") + .font(.title2.bold()) + .foregroundStyle(.orange) + .frame(width: 54, height: 54) + .background(Color.orange.opacity(0.12), in: Circle()) + Text(WalletFormatting.sats(quote.amountSat)) + .font(.title2.bold()) + Text(quote.rail.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + Section("Quote") { + ValueRow(label: "Amount", value: WalletFormatting.sats(quote.amountSat)) + ValueRow( + label: "Expected fee", + value: quote.feeKnown ? WalletFormatting.sats(quote.expectedFeeSat) : "Finalized during payment" + ) + if quote.totalOutflowKnown ?? quote.feeKnown { + ValueRow(label: "Expected total", value: WalletFormatting.sats(quote.expectedTotalOutflowSat)) + } + ValueRow(label: "Destination", value: quote.destinationSummary, copyable: true) + if let description = quote.invoiceDescription, !description.isEmpty { + ValueRow(label: "Invoice note", value: description) + } + if !note.isEmpty { ValueRow(label: "Your note", value: note) } + } + + if !quote.warning.isEmpty { + Section { + Label(quote.warning, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + + Section { + Button(role: .destructive) { + send(quote) + } label: { + HStack { + Spacer() + if isLoading { ProgressView().padding(.trailing, 6) } + Text("Confirm and Send") + Spacer() + } + } + .disabled(isLoading) + + Button("Edit Payment") { self.quote = nil } + .disabled(isLoading) + } footer: { + Text("This quote is single-use. Wavelength will never automatically retry a failed dispatch with a new payment identity.") + } + } + + private func successSection(_ result: SendResult) -> some View { + Section { + VStack(spacing: 14) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 58)) + .foregroundStyle(.green) + Text("Payment submitted").font(.title2.bold()) + Text(WalletFormatting.sats(result.actualAmountSat)) + .font(.title3.weight(.semibold)) + Text("Track settlement in Activity.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + } + + private var destinationHint: String { + if destination.isEmpty { return "Scan or paste a BOLT 11 invoice or a network-matching on-chain address." } + return isLightning ? "Lightning invoice detected" : "On-chain Bitcoin address detected" + } + + private var canPrepare: Bool { + guard store.isReady, store.offchainAvailable, + !normalizedDestination.isEmpty else { return false } + if isLightning { return true } + return amountSat > 0 + } + + private func prepare() { + isLoading = true + Task { + defer { isLoading = false } + do { + quote = try await store.prepareSend( + destination: normalizedDestination, + amountSat: amountSat, + note: note.trimmingCharacters(in: .whitespacesAndNewlines), + maxFeeSat: maxFeeSat + ) + } catch { + errorMessage = error.walletMessage + } + } + } + + private func send(_ prepared: PrepareSendResult) { + isLoading = true + // Consume the local quote before dispatch. The intent is single-use; + // an error requires an explicit fresh prepare rather than replaying it. + quote = nil + Task { + defer { isLoading = false } + do { + sentResult = try await store.sendPrepared(intentID: prepared.sendIntentID) + } catch { + errorMessage = "The payment result is not confirmed. Review Activity, then prepare a fresh quote if you still need to pay. \(error.walletMessage)" + } + } + } + + private var errorIsPresented: Binding { + Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + } +} diff --git a/ios/Sample/Sources/SettingsView.swift b/ios/Sample/Sources/SettingsView.swift new file mode 100644 index 0000000..64da66a --- /dev/null +++ b/ios/Sample/Sources/SettingsView.swift @@ -0,0 +1,157 @@ +import SwiftUI +import WalletKit + +struct SettingsView: View { + @EnvironmentObject private var store: WalletStore + @State private var proposedMainnet = false + @State private var isSaving = false + + var body: some View { + Form { + Section("Bitcoin network") { + ForEach(WalletNetwork.selectableNetworks, id: \.rawValue) { network in + Button { + select(network) + } label: { + HStack(spacing: 12) { + Circle().fill(network.color).frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(network.title).foregroundStyle(.primary) + Text(network.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if store.network == network { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(network.color) + } + } + } + } + } + + Section("Wallet backend") { + Label("Esplora lightweight wallet", systemImage: "network") + Text("Wavelength uses its in-process lwwallet backend for chain sync. No LND node or LND wallet is bundled or required.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Connection") { + HStack { + Label("Wallet", systemImage: "bitcoinsign.circle") + Spacer() + Text(store.isReady ? "Ready" : "Syncing") + .foregroundStyle(store.isReady ? .green : .orange) + } + HStack { + Label("Block height", systemImage: "cube") + Spacer() + Text("\(store.info?.blockHeight ?? 0)") + .foregroundStyle(.secondary) + } + HStack { + Label("Ark operator", systemImage: "antenna.radiowaves.left.and.right") + Spacer() + Text(store.info?.serverConnected == true ? "Connected" : "Offline") + .foregroundStyle(store.info?.serverConnected == true ? .green : .secondary) + } + } + + Section { + TextField("Operator host:port", text: $store.operatorAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Swap server host:port", text: $store.swapServerAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Esplora API URL", text: $store.esploraURL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + Button { + isSaving = true + Task { + await store.saveEndpointOverrides() + isSaving = false + } + } label: { + HStack { + if isSaving { ProgressView().padding(.trailing, 5) } + Text("Save and Restart Wallet") + } + } + .disabled(isSaving) + + Button("Use Network Defaults") { + store.operatorAddress = "" + store.swapServerAddress = "" + store.esploraURL = "" + isSaving = true + Task { + await store.saveEndpointOverrides() + isSaving = false + } + } + .disabled(isSaving) + } header: { + Text("Advanced endpoints") + } footer: { + if store.network == .mainnet { + Text("Mainnet chain sync can use the bundled Esplora default. Creating Wavelength boarding addresses requires a trusted operator; Lightning additionally requires a trusted swap endpoint. Empty service fields do not enable a public mainnet service.") + } else if store.network == .regtest { + Text("Debug-only regtest requires the local stack’s operator, swap, and Esplora endpoints. Launch environment variables can supply all three.") + } else { + Text("Leave fields empty to use the current defaults compiled into Wavelength. Overrides must use endpoints for \(store.network.title).") + } + } + + Section("Security") { + Label("Self-custodial seed", systemImage: "key.fill") + Label("Device-bound Keychain encryption", systemImage: "lock.iphone") + Text("Each Bitcoin network has an isolated wallet database and encryption key. Recovery words are only shown during wallet creation.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("About") { + HStack { + Text("Wavelength") + Spacer() + Text("1.0").foregroundStyle(.secondary) + } + if let version = store.info?.version, !version.isEmpty { + HStack { + Text("Embedded wallet") + Spacer() + Text(version).foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Settings") + .confirmationDialog( + "Use real bitcoin on Mainnet?", + isPresented: $proposedMainnet, + titleVisibility: .visible + ) { + Button("Switch to Mainnet", role: .destructive) { + Task { await store.switchNetwork(to: .mainnet) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Mainnet uses a separate wallet and real funds. Off-chain operations remain unavailable until you configure trusted services.") + } + } + + private func select(_ network: WalletNetwork) { + guard network != store.network else { return } + if network == .mainnet { + proposedMainnet = true + } else { + Task { await store.switchNetwork(to: network) } + } + } +} diff --git a/ios/Sample/Sources/Support.swift b/ios/Sample/Sources/Support.swift new file mode 100644 index 0000000..9ebb550 --- /dev/null +++ b/ios/Sample/Sources/Support.swift @@ -0,0 +1,177 @@ +import Foundation +import Security +import SwiftUI +import WalletKit + +enum WalletPhase: Equatable { + case idle + case starting + case needsSetup + case needsUnlock + case syncing + case ready + case failed(String) + + var isWalletAvailable: Bool { + self == .syncing || self == .ready + } +} + +extension WalletNetwork { + static var selectableNetworks: [WalletNetwork] { + #if DEBUG + return allCases + #else + return [.signet, .testnet, .mainnet] + #endif + } + + var title: String { + switch self { + case .signet: return "Signet" + case .testnet: return "Testnet" + case .mainnet: return "Mainnet" + case .regtest: return "Regtest" + } + } + + var subtitle: String { + switch self { + case .signet: return "Safe development coins" + case .testnet: return "Public Bitcoin test network" + case .mainnet: return "Real bitcoin" + case .regtest: return "Local integration stack" + } + } + + var color: Color { + switch self { + case .signet: return .purple + case .testnet: return .blue + case .mainnet: return .orange + case .regtest: return .pink + } + } + + func transactionURL(txid: String) -> URL? { + let path: String + switch self { + case .signet: path = "https://mempool.space/signet/tx/" + case .testnet: path = "https://mempool.space/testnet/tx/" + case .mainnet: path = "https://mempool.space/tx/" + case .regtest: return nil + } + return URL(string: path + txid) + } + + func addressURL(address: String) -> URL? { + let path: String + switch self { + case .signet: path = "https://mempool.space/signet/address/" + case .testnet: path = "https://mempool.space/testnet/address/" + case .mainnet: path = "https://mempool.space/address/" + case .regtest: return nil + } + return URL(string: path + address) + } +} + +enum WalletFormatting { + static let satoshis: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + return formatter + }() + + static func sats(_ value: Int64, signed: Bool = false) -> String { + let magnitude = satoshis.string(from: NSNumber(value: abs(value))) ?? "\(abs(value))" + if signed { + return (value < 0 ? "−" : "+") + magnitude + " sats" + } + return magnitude + " sats" + } + + static func date(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = parser.date(from: value) ?? ISO8601DateFormatter().date(from: value) + guard let date else { return nil } + return date.formatted(date: .abbreviated, time: .shortened) + } + + static func shortened(_ value: String, head: Int = 10, tail: Int = 8) -> String { + guard value.count > head + tail + 1 else { return value } + return "\(value.prefix(head))…\(value.suffix(tail))" + } +} + +enum KeychainStore { + private static let service = "engineering.lightning.wavelength.wallet" + + static func read(account: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw KeychainError(status: status) } + return result as? Data + } + + static func save(_ data: Data, account: String) throws { + let key: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + let updateStatus = SecItemUpdate(key as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw KeychainError(status: updateStatus) + } + + var item = key + attributes.forEach { item[$0.key] = $0.value } + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw KeychainError(status: addStatus) } + } + + static func randomSecret(byteCount: Int = 32) throws -> Data { + var bytes = [UInt8](repeating: 0, count: byteCount) + let status = bytes.withUnsafeMutableBytes { buffer in + SecRandomCopyBytes(kSecRandomDefault, byteCount, buffer.baseAddress!) + } + guard status == errSecSuccess else { throw KeychainError(status: status) } + return Data(bytes) + } +} + +struct KeychainError: LocalizedError { + let status: OSStatus + var errorDescription: String? { + SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)" + } +} + +extension Error { + var walletMessage: String { + if let localized = self as? LocalizedError, + let description = localized.errorDescription { + return description + } + return (self as NSError).localizedDescription + } +} diff --git a/ios/Sample/Sources/WalletHomeViews.swift b/ios/Sample/Sources/WalletHomeViews.swift new file mode 100644 index 0000000..2bbcd06 --- /dev/null +++ b/ios/Sample/Sources/WalletHomeViews.swift @@ -0,0 +1,216 @@ +import SwiftUI + +struct WalletShellView: View { + @EnvironmentObject private var store: WalletStore + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + NavigationView { + WalletDashboardView(showAllActivity: { selectedTab = 1 }) + } + .navigationViewStyle(.stack) + .tabItem { Label("Wallet", systemImage: "bitcoinsign.circle.fill") } + .tag(0) + + NavigationView { + ActivityListView() + } + .navigationViewStyle(.stack) + .tabItem { Label("Activity", systemImage: "clock.fill") } + .tag(1) + + NavigationView { + SettingsView() + } + .navigationViewStyle(.stack) + .tabItem { Label("Settings", systemImage: "gearshape.fill") } + .tag(2) + } + } +} + +private struct WalletDashboardView: View { + @EnvironmentObject private var store: WalletStore + @State private var showingSend = false + @State private var showingReceive = false + let showAllActivity: () -> Void + + var body: some View { + ScrollView { + VStack(spacing: 20) { + if store.isSyncing { + syncBanner + } + + balanceCard + actionButtons + recentActivity + } + .padding(.horizontal, 18) + .padding(.bottom, 24) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Wavelength") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + NetworkBadge(network: store.network) + } + } + .refreshable { await store.refresh() } + .sheet(isPresented: $showingSend) { + SendView().environmentObject(store) + } + .sheet(isPresented: $showingReceive) { + ReceiveView().environmentObject(store) + } + } + + private var syncBanner: some View { + HStack(spacing: 12) { + ProgressView().tint(.orange) + VStack(alignment: .leading, spacing: 2) { + Text("Syncing with Bitcoin").font(.subheadline.weight(.semibold)) + Text("Height \(store.info?.blockHeight ?? 0) · spending unlocks when ready") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(14) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14)) + } + + private var balanceCard: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Available balance") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white.opacity(0.82)) + Spacer() + Image(systemName: "lock.shield.fill") + .foregroundStyle(.white.opacity(0.86)) + .accessibilityLabel("Self-custodial wallet") + } + + HStack(alignment: .firstTextBaseline, spacing: 7) { + Text(WalletFormatting.satoshis.string( + from: NSNumber(value: store.confirmedBalanceSat) + ) ?? "0") + .font(.system(size: 38, weight: .bold, design: .rounded)) + .minimumScaleFactor(0.65) + .lineLimit(1) + Text("sats") + .font(.title3.weight(.medium)) + .foregroundStyle(.white.opacity(0.76)) + } + + HStack(spacing: 22) { + pendingValue(title: "Incoming", value: store.pendingInSat, icon: "arrow.down.left") + pendingValue(title: "Outgoing", value: store.pendingOutSat, icon: "arrow.up.right") + } + } + .foregroundStyle(.white) + .padding(22) + .background( + LinearGradient( + colors: [Color(red: 0.15, green: 0.13, blue: 0.20), Color(red: 0.33, green: 0.17, blue: 0.10)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 24) + ) + .shadow(color: .black.opacity(0.12), radius: 16, y: 8) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("wallet.balance") + } + + private func pendingValue(title: String, value: Int64, icon: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Label(title, systemImage: icon) + .font(.caption) + .foregroundStyle(.white.opacity(0.66)) + Text(WalletFormatting.sats(value)) + .font(.caption.weight(.semibold)) + } + } + + private var actionButtons: some View { + HStack(spacing: 16) { + walletAction(title: "Send", systemImage: "arrow.up", enabled: store.isReady) { + showingSend = true + } + walletAction(title: "Receive", systemImage: "arrow.down", enabled: store.isReady) { + showingReceive = true + } + } + .padding(.horizontal, 24) + } + + private func walletAction( + title: String, + systemImage: String, + enabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + VStack(spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .semibold)) + .frame(width: 52, height: 52) + .background(Color.orange, in: Circle()) + .foregroundStyle(.white) + Text(title).font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .disabled(!enabled) + .opacity(enabled ? 1 : 0.45) + .accessibilityIdentifier("wallet.\(title.lowercased())") + .accessibilityHint(enabled ? "" : "Available after wallet sync completes") + } + + private var recentActivity: some View { + VStack(spacing: 0) { + HStack { + Text("Recent activity").font(.headline) + Spacer() + if !store.activity.isEmpty { + Button("See All", action: showAllActivity) + .font(.subheadline.weight(.medium)) + } + } + .padding(.bottom, 10) + + if store.activity.isEmpty { + VStack(spacing: 10) { + Image(systemName: "clock.arrow.circlepath") + .font(.title) + .foregroundStyle(.secondary) + Text("No activity yet").font(.subheadline.weight(.medium)) + Text("Send or receive bitcoin and it will appear here.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16)) + } else { + VStack(spacing: 0) { + ForEach(Array(store.activity.prefix(4).enumerated()), id: \.element.id) { index, entry in + NavigationLink(destination: ActivityDetailView(entry: entry)) { + ActivityRow(entry: entry) + } + .buttonStyle(.plain) + .accessibilityIdentifier("activity.entry") + if index < min(store.activity.count, 4) - 1 { Divider().padding(.leading, 55) } + } + } + .padding(.horizontal, 14) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16)) + } + } + } +} diff --git a/ios/Sample/Sources/WalletStore.swift b/ios/Sample/Sources/WalletStore.swift new file mode 100644 index 0000000..56b1557 --- /dev/null +++ b/ios/Sample/Sources/WalletStore.swift @@ -0,0 +1,485 @@ +import Combine +import Foundation +import WalletKit + +private struct ReceiveCreationUncertainError: LocalizedError { + var errorDescription: String? { + "The receive request ended before Wavelength could confirm the result. Wavelength couldn’t find a newly created invoice in Activity. Check Activity before trying again." + } +} + +@MainActor +final class WalletStore: ObservableObject { + @Published private(set) var phase: WalletPhase = .idle + @Published private(set) var info: Info? + @Published private(set) var balance: Balance? + @Published private(set) var activity: [Entry] = [] + @Published private(set) var isWorking = false + @Published var pendingMnemonic: [String]? + @Published var alertMessage: String? + + @Published private(set) var network: WalletNetwork + @Published var operatorAddress: String + @Published var swapServerAddress: String + @Published var esploraURL: String + + private let client = WalletClient() + private let defaults: UserDefaults + private var refreshTask: Task? + private var activityTask: Task? + private var generation = 0 + private var stateCreatingCallCount = 0 + private var foregroundRestartPending = false + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + #if DEBUG + let debugRegtest = ProcessInfo.processInfo.environment["WAVELENGTH_REGTEST"] == "1" + #else + let debugRegtest = false + #endif + let selected = debugRegtest + ? WalletNetwork.regtest.rawValue + : defaults.string(forKey: "selectedNetwork") ?? WalletNetwork.signet.rawValue + network = WalletNetwork(rawValue: selected) ?? .signet + operatorAddress = "" + swapServerAddress = "" + esploraURL = "" + loadEndpointOverrides() + #if DEBUG + if debugRegtest { + let environment = ProcessInfo.processInfo.environment + operatorAddress = environment["WAVELENGTH_OPERATOR_ADDRESS"] ?? operatorAddress + swapServerAddress = environment["WAVELENGTH_SWAP_ADDRESS"] ?? swapServerAddress + esploraURL = environment["WAVELENGTH_ESPLORA_URL"] ?? esploraURL + } + #endif + } + + var isReady: Bool { phase == .ready } + var isSyncing: Bool { phase == .syncing } + var confirmedBalanceSat: Int64 { balance?.confirmedSat ?? 0 } + var pendingInSat: Int64 { balance?.pendingInSat ?? 0 } + var pendingOutSat: Int64 { balance?.pendingOutSat ?? 0 } + + func transactionExplorerURL(txid: String) -> URL? { + customEsploraResourceURL(kind: "tx", identifier: txid) ?? + network.transactionURL(txid: txid) + } + + func addressExplorerURL(address: String) -> URL? { + customEsploraResourceURL(kind: "address", identifier: address) ?? + network.addressURL(address: address) + } + + /// Mainnet deliberately has no built-in public Ark service. Esplora can + /// sync the backing wallet by itself, but boarding-address construction + /// still requires operator terms. + var operatorAvailable: Bool { + if network == .mainnet || network == .regtest { + return !operatorAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return true + } + + /// Lightning receive/send additionally requires the swap service. + var offchainAvailable: Bool { + if network == .mainnet || network == .regtest { + return operatorAvailable && + !swapServerAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return true + } + + func start() async { + generation += 1 + let currentGeneration = generation + stopMonitoring() + phase = .starting + info = nil + balance = nil + activity = [] + + do { + if await client.isRunning { + try await client.stop() + } + let config = try walletConfig() + try await client.start(config) + guard currentGeneration == generation else { return } + try await resolveLifecycle() + } catch { + guard currentGeneration == generation else { return } + phase = .failed(error.walletMessage) + } + } + + func retry() { + Task { await start() } + } + + /// Re-dial transports after iOS has frozen the embedded daemon in the + /// background. If a state-creating call is still returning, defer teardown + /// until its outcome can be reconciled instead of cancelling it midway. + func resumeAfterBackground() async { + foregroundRestartPending = true + await restartForForegroundIfSafe() + } + + func switchNetwork(to selected: WalletNetwork) async { + guard selected != network else { return } + network = selected + defaults.set(selected.rawValue, forKey: "selectedNetwork") + loadEndpointOverrides() + await start() + } + + func saveEndpointOverrides() async { + defaults.set(operatorAddress.trimmingCharacters(in: .whitespacesAndNewlines), + forKey: endpointKey("operator")) + defaults.set(swapServerAddress.trimmingCharacters(in: .whitespacesAndNewlines), + forKey: endpointKey("swap")) + defaults.set(esploraURL.trimmingCharacters(in: .whitespacesAndNewlines), + forKey: endpointKey("esplora")) + await start() + } + + func createWallet(mnemonic: [String] = [], showBackup: Bool = true) async { + guard phase == .needsSetup else { return } + beginStateCreatingCall() + defer { endStateCreatingCall() } + isWorking = true + defer { isWorking = false } + + do { + // Persist the exact password before creating the wallet. If the + // daemon succeeds but its response is lost, a later unlock must + // use this same durable secret rather than creating a new one. + let account = keychainAccount + let password = try KeychainStore.read(account: account) ?? KeychainStore.randomSecret() + try KeychainStore.save(password, account: account) + + let result = try await client.createWallet( + walletPassword: password, + mnemonic: mnemonic + ) + pendingMnemonic = showBackup ? result.mnemonic : nil + try await activateWallet() + } catch { + alertMessage = "Wallet setup didn’t finish. Your device keeps the same encrypted wallet key so it can safely resume: \(error.walletMessage)" + await refreshLifecycleAfterAmbiguousSetup() + } + } + + func unlockWallet(password: String) async { + guard phase == .needsUnlock, !password.isEmpty else { return } + beginStateCreatingCall() + defer { endStateCreatingCall() } + isWorking = true + defer { isWorking = false } + do { + let secret = Data(password.utf8) + _ = try await client.unlockWallet(walletPassword: secret) + try KeychainStore.save(secret, account: keychainAccount) + try await activateWallet() + } catch { + alertMessage = "Couldn’t unlock this wallet: \(error.walletMessage)" + } + } + + func acknowledgeSeedBackup() { + pendingMnemonic = nil + } + + func refresh() async { + do { + let nextInfo = try await client.getInfo() + info = nextInfo + phase = nextInfo.walletReady ? .ready : .syncing + + if nextInfo.walletState == .ready { + // Publish each independent snapshot as soon as it arrives. A + // slow optional credit lookup must not hide newer Activity, + // and a history error must not suppress a valid balance. + async let balanceRefresh: Void = refreshBalanceSnapshot() + async let activityRefresh: Void = refreshActivitySnapshot() + _ = await (balanceRefresh, activityRefresh) + } + } catch { + // A transient refresh failure does not mean the daemon stopped or + // that a funds-moving operation failed. Keep the current state and + // let the next observation reconcile it. + } + } + + func prepareSend( + destination: String, + amountSat: Int64, + note: String, + maxFeeSat: Int64 + ) async throws -> PrepareSendResult { + if destination.lowercased().hasPrefix("ln") { + return try await client.prepareSend( + invoice: destination, + note: note, + maxFeeSat: maxFeeSat + ) + } + return try await client.prepareSend( + onchainAddress: destination, + amountSat: amountSat, + note: note + ) + } + + func sendPrepared(intentID: String) async throws -> SendResult { + beginStateCreatingCall() + defer { endStateCreatingCall() } + let result = try await client.send(intentID) + upsert(result.entry) + await refresh() + return result + } + + func receiveLightning(amountSat: Int64, memo: String) async throws -> ReceiveResult { + beginStateCreatingCall() + defer { endStateCreatingCall() } + let existingEntryIDs = Set(activity.map(\.id)) + + do { + let result = try await client.receiveLightning( + amountSat: amountSat, + memo: memo, + timeoutSeconds: 20 + ) + upsert(result.entry) + return result + } catch { + guard let walletError = error as? WalletError, + walletError.isReceiveOutcomeUncertain || + walletError.isDeadlineExceeded else { + throw error + } + + // A request deadline or lifecycle cancellation can race durable + // creation. Reconcile Activity and recover that exact invoice. + // Never issue a second state-creating call automatically after an + // uncertain result. + await refreshActivitySnapshot() + if let recovered = recoveredReceive( + amountSat: amountSat, + memo: memo, + excluding: existingEntryIDs + ) { + return recovered + } + + throw ReceiveCreationUncertainError() + } + } + + func newDepositAddress(amountHintSat: Int64) async throws -> DepositResult { + beginStateCreatingCall() + defer { endStateCreatingCall() } + // Allocating an address does not mean funds are in flight. The daemon + // deliberately does not persist Deposit's request-only Entry; it adds + // the canonical deposit row once Esplora observes a UTXO. Keep the + // address on the Receive screen and let List/Subscribe surface real + // activity with the observed amount. + return try await client.newDepositAddress(amountSatHint: amountHintSat) + } + + private func resolveLifecycle() async throws { + let snapshot = try await client.getInfo() + info = snapshot + switch snapshot.walletState { + case .none: + phase = .needsSetup + case .locked: + if let password = try KeychainStore.read(account: keychainAccount) { + do { + _ = try await client.unlockWallet(walletPassword: password) + try await activateWallet() + } catch { + phase = .needsUnlock + } + } else { + phase = .needsUnlock + } + case .ready, .syncing: + try await activateWallet() + case .unspecified: + phase = .syncing + beginMonitoring() + } + } + + private func activateWallet() async throws { + let snapshot = try await client.getInfo() + info = snapshot + phase = snapshot.walletReady ? .ready : .syncing + await refresh() + beginMonitoring() + } + + private func refreshLifecycleAfterAmbiguousSetup() async { + guard let snapshot = try? await client.getInfo() else { return } + info = snapshot + switch snapshot.walletState { + case .none: phase = .needsSetup + case .locked: phase = .needsUnlock + case .ready, .syncing: + try? await activateWallet() + case .unspecified: phase = .syncing + } + } + + private func beginMonitoring() { + stopMonitoring() + let currentGeneration = generation + + refreshTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, currentGeneration == self.generation else { return } + await self.refresh() + } + } + + let stream = client.activity(includeExisting: true) + activityTask = Task { [weak self] in + do { + for try await entry in stream { + guard let self, currentGeneration == self.generation else { return } + self.upsert(entry) + } + } catch { + // A later refresh reconciles the list. Stream termination is + // expected when changing networks or stopping the daemon. + } + } + } + + private func stopMonitoring() { + refreshTask?.cancel() + activityTask?.cancel() + refreshTask = nil + activityTask = nil + } + + private func upsert(_ entry: Entry) { + if let index = activity.firstIndex(where: { $0.id == entry.id }) { + activity[index] = entry + } else { + activity.insert(entry, at: 0) + } + } + + private func recoveredReceive( + amountSat: Int64, + memo: String, + excluding existingEntryIDs: Set + ) -> ReceiveResult? { + guard let entry = activity.first(where: { + !existingEntryIDs.contains($0.id) && + $0.kind == "receive" && + $0.amountSat == amountSat && + $0.note == memo && + $0.request?.type == "lightning" && + !($0.request?.lightningInvoice.isEmpty ?? true) + }), let invoice = entry.request?.lightningInvoice else { + return nil + } + + return ReceiveResult(invoice: invoice, entry: entry) + } + + private func refreshBalanceSnapshot() async { + guard let snapshot = try? await client.balance() else { return } + balance = snapshot + } + + private func refreshActivitySnapshot() async { + guard let result = try? await client.list( + view: .activity, + limit: 100 + ), let entries = result.activity?.entries else { + return + } + + // List is the daemon's authoritative, already-deduplicated activity + // view in most-recent-first order. Replacing the snapshot also removes + // terminal or request-only rows that are no longer wallet activity. + activity = entries + } + + private func beginStateCreatingCall() { + stateCreatingCallCount += 1 + } + + private func endStateCreatingCall() { + stateCreatingCallCount = max(0, stateCreatingCallCount - 1) + guard stateCreatingCallCount == 0, foregroundRestartPending else { + return + } + + Task { [weak self] in + await self?.restartForForegroundIfSafe() + } + } + + private func restartForForegroundIfSafe() async { + guard foregroundRestartPending, stateCreatingCallCount == 0 else { + return + } + foregroundRestartPending = false + await start() + } + + private func walletConfig() throws -> WalletConfig { + let support = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let directory = support + .appendingPathComponent("Wavelength", isDirectory: true) + .appendingPathComponent(network.rawValue, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return .network( + network, + dataDir: directory.path, + esploraURL: esploraURL.trimmingCharacters(in: .whitespacesAndNewlines), + operatorAddress: operatorAddress.trimmingCharacters(in: .whitespacesAndNewlines), + swapServerAddress: swapServerAddress.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + private var keychainAccount: String { "wallet-password-\(network.rawValue)" } + + private func endpointKey(_ endpoint: String) -> String { + "\(network.rawValue).\(endpoint)Address" + } + + private func customEsploraResourceURL(kind: String, identifier: String) -> URL? { + let value = esploraURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, var base = URL(string: value) else { return nil } + // Hosted Esplora deployments such as mempool.space and + // blockstream.info expose JSON below /api but their human-readable + // transaction/address pages live one level above it. + if base.lastPathComponent == "api" { + base.deleteLastPathComponent() + } + return base.appendingPathComponent(kind).appendingPathComponent(identifier) + } + + private func loadEndpointOverrides() { + operatorAddress = defaults.string(forKey: endpointKey("operator")) ?? "" + swapServerAddress = defaults.string(forKey: endpointKey("swap")) ?? "" + esploraURL = defaults.string(forKey: endpointKey("esplora")) ?? "" + } +} diff --git a/ios/Sample/Sources/WavewalletdkSampleApp.swift b/ios/Sample/Sources/WavewalletdkSampleApp.swift index a1ce6b9..2c657e1 100644 --- a/ios/Sample/Sources/WavewalletdkSampleApp.swift +++ b/ios/Sample/Sources/WavewalletdkSampleApp.swift @@ -1,10 +1,31 @@ import SwiftUI @main -struct WavewalletdkSampleApp: App { +struct WavelengthApp: App { + @Environment(\.scenePhase) private var scenePhase + var body: some Scene { WindowGroup { - ContentView() + ZStack { + ContentView() + if scenePhase != .active { + PrivacyCover() + } + } + .tint(.orange) + } + } +} + +private struct PrivacyCover: View { + var body: some View { + ZStack { + Color(.systemBackground).ignoresSafeArea() + VStack(spacing: 16) { + WavelengthMark(size: 70) + Text("Wavelength").font(.title2.bold()) + } } + .accessibilityHidden(true) } } diff --git a/ios/Sample/Tests/PaymentRequestParserTests.swift b/ios/Sample/Tests/PaymentRequestParserTests.swift new file mode 100644 index 0000000..80c5ed3 --- /dev/null +++ b/ios/Sample/Tests/PaymentRequestParserTests.swift @@ -0,0 +1,35 @@ +import XCTest +import WalletKit + +final class PaymentRequestParserTests: XCTestCase { + func testRawInvoiceIsUnchangedApartFromWhitespace() { + XCTAssertEqual( + PaymentRequestParser.normalizedDestination(from: " lntbs1invoice\n"), + "lntbs1invoice" + ) + } + + func testLightningURIIsUnwrappedCaseInsensitively() { + XCTAssertEqual( + PaymentRequestParser.normalizedDestination(from: "LIGHTNING://LNTBS1INVOICE"), + "LNTBS1INVOICE" + ) + } + + func testBIP21PrefersEmbeddedLightningInvoice() { + let request = "bitcoin:tb1ptest?amount=0.001&lightning=LIGHTNING%3Alntbs1invoice" + XCTAssertEqual( + PaymentRequestParser.normalizedDestination(from: request), + "lntbs1invoice" + ) + } + + func testBIP21WithoutLightningReturnsAddress() { + XCTAssertEqual( + PaymentRequestParser.normalizedDestination( + from: "bitcoin:tb1ptest?amount=0.001&label=Wavelength" + ), + "tb1ptest" + ) + } +} diff --git a/ios/Sample/Tests/WalletConfigurationTests.swift b/ios/Sample/Tests/WalletConfigurationTests.swift new file mode 100644 index 0000000..9148819 --- /dev/null +++ b/ios/Sample/Tests/WalletConfigurationTests.swift @@ -0,0 +1,79 @@ +import XCTest +import WalletKit + +final class WalletConfigurationTests: XCTestCase { + func testNetworkConfigsUseLightweightWallet() throws { + for network in WalletNetwork.allCases { + let config = WalletConfig.network(network, dataDir: "/tmp/\(network.rawValue)") + let object = try encodedObject(config) + + XCTAssertEqual(object["network"] as? String, network.rawValue) + XCTAssertEqual(object["wallet_type"] as? String, "lwwallet") + let expectedPollInterval: Int = network == .regtest ? 1 : + (network == .mainnet ? 30 : 10) + XCTAssertEqual(object["wallet_poll_interval_seconds"] as? Int, expectedPollInterval) + XCTAssertNil(object["wallet_esplora_url"]) + XCTAssertNil(object["server_address"]) + XCTAssertNil(object["swap_server_address"]) + XCTAssertEqual(object["allow_mainnet"] as? Bool, network == .mainnet) + XCTAssertEqual(object["server_insecure"] as? Bool, network == .regtest ? true : nil) + XCTAssertEqual(object["swap_server_insecure"] as? Bool, network == .regtest ? true : nil) + } + } + + func testCustomEndpointsAreEncoded() throws { + let config = WalletConfig.network( + .mainnet, + dataDir: "/tmp/mainnet", + esploraURL: "https://esplora.example/api", + operatorAddress: "ark.example:443", + swapServerAddress: "swap.example:443" + ) + let object = try encodedObject(config) + + XCTAssertEqual(object["wallet_esplora_url"] as? String, "https://esplora.example/api") + XCTAssertEqual(object["server_address"] as? String, "ark.example:443") + XCTAssertEqual(object["swap_server_address"] as? String, "swap.example:443") + XCTAssertEqual(object["server_transport"] as? String, "grpc") + XCTAssertEqual(object["allow_mainnet"] as? Bool, true) + } + + func testHTTPRegtestEndpointsSelectInsecureREST() throws { + let config = WalletConfig.network( + .regtest, + dataDir: "/tmp/regtest", + esploraURL: "http://127.0.0.1:3002", + operatorAddress: "http://127.0.0.1:7070", + swapServerAddress: "http://127.0.0.1:10030" + ) + let object = try encodedObject(config) + + XCTAssertEqual(object["server_transport"] as? String, "rest") + XCTAssertEqual(object["server_insecure"] as? Bool, true) + XCTAssertEqual(object["swap_server_transport"] as? String, "rest") + XCTAssertEqual(object["swap_server_insecure"] as? Bool, true) + } + + func testCertificatePathsKeepRegtestTLS() throws { + let config = WalletConfig.network( + .regtest, + dataDir: "/tmp/regtest", + operatorAddress: "127.0.0.1:7070", + swapServerAddress: "127.0.0.1:10029", + operatorTLSCertPath: "/tmp/operator.cert", + swapTLSCertPath: "/tmp/swap.cert" + ) + let object = try encodedObject(config) + + XCTAssertEqual(object["server_transport"] as? String, "grpc") + XCTAssertEqual(object["server_tls_cert_path"] as? String, "/tmp/operator.cert") + XCTAssertNil(object["server_insecure"]) + XCTAssertEqual(object["swap_server_tls_cert_path"] as? String, "/tmp/swap.cert") + XCTAssertNil(object["swap_server_insecure"]) + } + + private func encodedObject(_ config: WalletConfig) throws -> [String: Any] { + let data = try JSONEncoder().encode(config) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} diff --git a/ios/Sample/Tests/WalletErrorTests.swift b/ios/Sample/Tests/WalletErrorTests.swift new file mode 100644 index 0000000..3b8f72c --- /dev/null +++ b/ios/Sample/Tests/WalletErrorTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import WalletKit + +final class WalletErrorTests: XCTestCase { + func testRecognizesUncertainReceiveOutcome() { + let error = WalletError( + message: "receive outcome uncertain; reconcile Activity before retrying: context canceled" + ) + + XCTAssertTrue(error.isReceiveOutcomeUncertain) + } + + func testDoesNotTreatGenericCancellationAsUncertainReceive() { + let error = WalletError(message: "context canceled") + + XCTAssertFalse(error.isReceiveOutcomeUncertain) + } + + func testRecognizesLegacyReceiveDeadline() { + let error = WalletError(message: "context deadline exceeded") + + XCTAssertTrue(error.isDeadlineExceeded) + XCTAssertFalse(error.isReceiveOutcomeUncertain) + } +} diff --git a/ios/Sample/Tests/WalletModelDecodingTests.swift b/ios/Sample/Tests/WalletModelDecodingTests.swift new file mode 100644 index 0000000..bb00b34 --- /dev/null +++ b/ios/Sample/Tests/WalletModelDecodingTests.swift @@ -0,0 +1,77 @@ +import XCTest +import WalletKit + +final class WalletModelDecodingTests: XCTestCase { + func testDecodesDetailedActivityEntry() throws { + let json = #""" + { + "ID": "activity-1", + "Kind": "receive", + "Status": "complete", + "AmountSat": 21000, + "FeeSat": 12, + "Counterparty": "peer", + "CreatedAt": "2026-08-11T10:00:00Z", + "UpdatedAt": "2026-08-11T10:01:00Z", + "Note": "coffee", + "FailureReason": "", + "FailureCode": "", + "Cursor": 42, + "Progress": { + "Phase": "confirmed", + "PhaseLabel": "confirmed", + "PaymentHash": "hash", + "Txid": "txid", + "ConfirmationHeight": 123, + "VTXOOutpoint": "outpoint:0", + "Preimage": "preimage" + }, + "Request": { + "Type": "lightning", + "LightningInvoice": "lnbc1example", + "PaymentHash": "hash", + "OnchainAddress": "", + "ArkAddress": "" + } + } + """#.data(using: .utf8)! + + let entry = try JSONDecoder().decode(Entry.self, from: json) + XCTAssertEqual(entry.id, "activity-1") + XCTAssertEqual(entry.amountSat, 21_000) + XCTAssertEqual(entry.progress?.phase, "confirmed") + XCTAssertEqual(entry.progress?.confirmationHeight, 123) + XCTAssertEqual(entry.request?.lightningInvoice, "lnbc1example") + XCTAssertEqual(entry.cursor, 42) + } + + func testOlderActivityPayloadStillDecodes() throws { + let json = #""" + { + "ID": "legacy", + "Kind": "deposit", + "Status": "pending", + "AmountSat": 1000, + "FeeSat": 0, + "Counterparty": "", + "Note": "" + } + """#.data(using: .utf8)! + + let entry = try JSONDecoder().decode(Entry.self, from: json) + XCTAssertEqual(entry.id, "legacy") + XCTAssertNil(entry.progress) + XCTAssertNil(entry.request) + XCTAssertNil(entry.createdAt) + } + + func testOlderBalancePayloadStillDecodes() throws { + let json = #""" + {"ConfirmedSat": 5000, "PendingInSat": 20, "PendingOutSat": 10} + """#.data(using: .utf8)! + + let balance = try JSONDecoder().decode(Balance.self, from: json) + XCTAssertEqual(balance.confirmedSat, 5_000) + XCTAssertNil(balance.creditAvailableSat) + } +} diff --git a/ios/Sample/UITests/WavelengthUITests.swift b/ios/Sample/UITests/WavelengthUITests.swift new file mode 100644 index 0000000..79c253c --- /dev/null +++ b/ios/Sample/UITests/WavelengthUITests.swift @@ -0,0 +1,103 @@ +import XCTest + +final class WavelengthUITests: XCTestCase { + private var environment: [String: String] { + ProcessInfo.processInfo.environment + } + + private func launchRegtestApp() throws -> XCUIApplication { + guard environment["WAVELENGTH_UI_REGTEST"] == "1" else { + throw XCTSkip("Set WAVELENGTH_UI_REGTEST=1 and the endpoint variables to run the live regtest UI tests") + } + let operatorAddress = try XCTUnwrap(environment["WAVELENGTH_OPERATOR_ADDRESS"]) + let swapAddress = try XCTUnwrap(environment["WAVELENGTH_SWAP_ADDRESS"]) + let esploraURL = try XCTUnwrap(environment["WAVELENGTH_ESPLORA_URL"]) + + let app = XCUIApplication() + app.launchEnvironment = [ + "WAVELENGTH_REGTEST": "1", + "WAVELENGTH_AUTOCREATE": "1", + "WAVELENGTH_OPERATOR_ADDRESS": operatorAddress, + "WAVELENGTH_SWAP_ADDRESS": swapAddress, + "WAVELENGTH_ESPLORA_URL": esploraURL, + ] + app.launch() + + let receive = app.buttons["wallet.receive"] + XCTAssertTrue(receive.waitForExistence(timeout: 30), "Wallet did not become ready") + return app + } + + func testCreateOnchainRequest() throws { + let app = try launchRegtestApp() + app.buttons["wallet.receive"].tap() + + let onchain = app.buttons["On-chain"] + XCTAssertTrue(onchain.waitForExistence(timeout: 5)) + onchain.tap() + + let create = app.buttons["receive.create"] + XCTAssertTrue(create.waitForExistence(timeout: 5)) + create.tap() + + let request = app.staticTexts["receive.request"] + XCTAssertTrue(request.waitForExistence(timeout: 20)) + XCTAssertTrue(request.label.hasPrefix("bcrt1"), request.label) + app.buttons["receive.copy"].tap() + print("WAVELENGTH_ONCHAIN_REQUEST=\(request.label)") + } + + func testCreateLightningRequest() throws { + let app = try launchRegtestApp() + app.buttons["wallet.receive"].tap() + + let amount = app.textFields["receive.amount"] + XCTAssertTrue(amount.waitForExistence(timeout: 5)) + amount.tap() + amount.typeText("50000") + + let create = app.buttons["receive.create"] + XCTAssertTrue(create.waitForExistence(timeout: 5)) + if !create.isHittable { + app.swipeUp() + } + create.tap() + + let request = app.staticTexts["receive.request"] + XCTAssertTrue(request.waitForExistence(timeout: 20)) + XCTAssertTrue(request.label.lowercased().hasPrefix("lnbcrt"), request.label) + app.buttons["receive.copy"].tap() + print("WAVELENGTH_LIGHTNING_REQUEST=\(request.label)") + } + + func testFundedWalletShowsBalanceAndActivity() throws { + guard environment["WAVELENGTH_UI_EXTERNAL_FUNDING"] == "1" else { + throw XCTSkip("This test prints an address and waits for external regtest funding") + } + let app = try launchRegtestApp() + + app.buttons["wallet.receive"].tap() + let onchain = app.buttons["On-chain"] + XCTAssertTrue(onchain.waitForExistence(timeout: 5)) + onchain.tap() + app.buttons["receive.create"].tap() + let request = app.staticTexts["receive.request"] + XCTAssertTrue(request.waitForExistence(timeout: 20)) + print("WAVELENGTH_FUND_THIS_ADDRESS=\(request.label)") + app.buttons["Done"].tap() + + let balance = app.staticTexts["wallet.balance"] + XCTAssertTrue(balance.waitForExistence(timeout: 30)) + let deadline = Date().addingTimeInterval(240) + while Date() < deadline && balance.label.contains("Incoming, 0 sats") { + RunLoop.current.run(until: Date().addingTimeInterval(2)) + } + XCTAssertFalse(balance.label.contains("Incoming, 0 sats"), balance.label) + + app.tabBars.buttons["Activity"].tap() + let entry = app.descendants(matching: .any)["activity.entry"].firstMatch + XCTAssertTrue(entry.waitForExistence(timeout: 30)) + entry.tap() + XCTAssertTrue(app.navigationBars["Activity Details"].waitForExistence(timeout: 10)) + } +} diff --git a/ios/Sample/project.yml b/ios/Sample/project.yml index 7b62171..ddf81d9 100644 --- a/ios/Sample/project.yml +++ b/ios/Sample/project.yml @@ -1,4 +1,4 @@ -name: WavewalletdkSample +name: Wavelength options: bundleIdPrefix: engineering.lightning.wavewalletdk @@ -13,7 +13,7 @@ packages: path: ../WalletKit targets: - WavewalletdkSample: + Wavelength: type: application platform: iOS sources: @@ -22,14 +22,61 @@ targets: - package: WalletKit settings: base: - # Go's net resolver (compiled into the xcframework) references - # res_9_* symbols from libresolv; link it explicitly. - OTHER_LDFLAGS: -lresolv - PRODUCT_BUNDLE_IDENTIFIER: engineering.lightning.wavewalletdk.sample + PRODUCT_BUNDLE_IDENTIFIER: engineering.lightning.wavelength.wallet + PRODUCT_NAME: Wavelength GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_CFBundleDisplayName: Wavelength + INFOPLIST_KEY_NSCameraUsageDescription: Scan Lightning invoices and Bitcoin payment QR codes. + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "5.9" TARGETED_DEVICE_FAMILY: "1" + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: YES + + WavelengthTests: + type: bundle.unit-test + platform: iOS + sources: + - Tests + dependencies: + - package: WalletKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: engineering.lightning.wavelength.wallet.tests + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: "5.9" + + WavelengthUITests: + type: bundle.ui-testing + platform: iOS + sources: + - UITests + dependencies: + - target: Wavelength + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: engineering.lightning.wavelength.wallet.uitests + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: "5.9" + +schemes: + Wavelength: + build: + targets: + Wavelength: all + WavelengthTests: [test] + WavelengthUITests: [test] + run: + config: Debug + test: + config: Debug + targets: + - WavelengthTests + - WavelengthUITests + archive: + config: Release diff --git a/ios/WalletKit/Package.swift b/ios/WalletKit/Package.swift index 4b093c0..8942b9c 100644 --- a/ios/WalletKit/Package.swift +++ b/ios/WalletKit/Package.swift @@ -21,7 +21,13 @@ let package = Package( ), .target( name: "WalletKit", - dependencies: ["Wavewalletdk"] + dependencies: ["Wavewalletdk"], + linkerSettings: [ + // The embedded Go resolver references res_9_* symbols. Keep + // this on the package so tests and downstream hosts link it + // without duplicating an app-target setting. + .linkedLibrary("resolv"), + ] ), ] ) diff --git a/ios/WalletKit/Sources/WalletKit/Models.swift b/ios/WalletKit/Sources/WalletKit/Models.swift index a77949b..d021cd6 100644 --- a/ios/WalletKit/Sources/WalletKit/Models.swift +++ b/ios/WalletKit/Sources/WalletKit/Models.swift @@ -47,11 +47,15 @@ public struct Balance: Decodable, Sendable { public let confirmedSat: Int64 public let pendingInSat: Int64 public let pendingOutSat: Int64 + public let creditAvailableSat: Int64? + public let creditReservedSat: Int64? enum CodingKeys: String, CodingKey { case confirmedSat = "ConfirmedSat" case pendingInSat = "PendingInSat" case pendingOutSat = "PendingOutSat" + case creditAvailableSat = "CreditAvailableSat" + case creditReservedSat = "CreditReservedSat" } } @@ -95,7 +99,7 @@ public struct UnlockWalletResult: Decodable, Sendable { } /// One activity entry from the wallet stream (`subscribe`). -public struct Entry: Decodable, Sendable { +public struct Entry: Decodable, Identifiable, Sendable { public let id: String public let kind: String public let status: String @@ -103,6 +107,13 @@ public struct Entry: Decodable, Sendable { public let feeSat: Int64 public let counterparty: String public let note: String + public let createdAt: String? + public let updatedAt: String? + public let failureReason: String? + public let failureCode: String? + public let cursor: Int64? + public let progress: EntryProgress? + public let request: EntryRequest? enum CodingKeys: String, CodingKey { case id = "ID" @@ -112,6 +123,51 @@ public struct Entry: Decodable, Sendable { case feeSat = "FeeSat" case counterparty = "Counterparty" case note = "Note" + case createdAt = "CreatedAt" + case updatedAt = "UpdatedAt" + case failureReason = "FailureReason" + case failureCode = "FailureCode" + case cursor = "Cursor" + case progress = "Progress" + case request = "Request" + } +} + +/// Best-effort lifecycle metadata for a wallet activity entry. +public struct EntryProgress: Decodable, Sendable { + public let phase: String + public let phaseLabel: String + public let paymentHash: String + public let txid: String + public let confirmationHeight: Int64 + public let vtxoOutpoint: String + public let preimage: String + + enum CodingKeys: String, CodingKey { + case phase = "Phase" + case phaseLabel = "PhaseLabel" + case paymentHash = "PaymentHash" + case txid = "Txid" + case confirmationHeight = "ConfirmationHeight" + case vtxoOutpoint = "VTXOOutpoint" + case preimage = "Preimage" + } +} + +/// User-recognizable request data retained with an activity entry. +public struct EntryRequest: Decodable, Sendable { + public let type: String + public let lightningInvoice: String + public let paymentHash: String + public let onchainAddress: String + public let arkAddress: String + + enum CodingKeys: String, CodingKey { + case type = "Type" + case lightningInvoice = "LightningInvoice" + case paymentHash = "PaymentHash" + case onchainAddress = "OnchainAddress" + case arkAddress = "ArkAddress" } } @@ -120,6 +176,11 @@ public struct ReceiveResult: Decodable, Sendable { public let invoice: String public let entry: Entry + public init(invoice: String, entry: Entry) { + self.invoice = invoice + self.entry = entry + } + enum CodingKeys: String, CodingKey { case invoice = "Invoice" case entry = "Entry" @@ -151,6 +212,11 @@ public struct PrepareSendResult: Decodable, Sendable { public let destinationSummary: String public let paymentHash: String public let warning: String + public let totalOutflowKnown: Bool? + public let invoiceDescription: String? + public let expiresAtUnix: Int64? + public let selectedOutpoints: [String]? + public let creditPreview: CreditPreview? enum CodingKeys: String, CodingKey { case sendIntentID = "SendIntentID" @@ -163,6 +229,28 @@ public struct PrepareSendResult: Decodable, Sendable { case destinationSummary = "DestinationSummary" case paymentHash = "PaymentHash" case warning = "Warning" + case totalOutflowKnown = "TotalOutflowKnown" + case invoiceDescription = "InvoiceDescription" + case expiresAtUnix = "ExpiresAtUnix" + case selectedOutpoints = "SelectedOutpoints" + case creditPreview = "CreditPreview" + } +} + +/// How a prepared send will use server credit, when applicable. +public struct CreditPreview: Decodable, Sendable { + public let mustUseCredit: Bool + public let creditAppliedSat: Int64 + public let creditShortfallSat: Int64 + public let creditTopupSat: Int64 + public let arkFundingSat: Int64 + + enum CodingKeys: String, CodingKey { + case mustUseCredit = "MustUseCredit" + case creditAppliedSat = "CreditAppliedSat" + case creditShortfallSat = "CreditShortfallSat" + case creditTopupSat = "CreditTopupSat" + case arkFundingSat = "ArkFundingSat" } } @@ -220,7 +308,14 @@ public struct OnchainTx: Decodable, Sendable { public struct ActivityList: Decodable, Sendable { public let entries: [Entry] public let total: Int64 - enum CodingKeys: String, CodingKey { case entries = "Entries"; case total = "Total" } + public let hasMore: Bool? + public let nextCursor: String? + enum CodingKeys: String, CodingKey { + case entries = "Entries" + case total = "Total" + case hasMore = "HasMore" + case nextCursor = "NextCursor" + } } public struct VTXOInventory: Decodable, Sendable { diff --git a/ios/WalletKit/Sources/WalletKit/PaymentRequestParser.swift b/ios/WalletKit/Sources/WalletKit/PaymentRequestParser.swift new file mode 100644 index 0000000..447d5b6 --- /dev/null +++ b/ios/WalletKit/Sources/WalletKit/PaymentRequestParser.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Normalizes QR-code and clipboard payment payloads into destinations accepted +/// by Wavelength's prepare-send API. +public enum PaymentRequestParser { + /// Returns a BOLT-11 invoice or Bitcoin address from a raw payment payload. + /// BIP-21 requests containing a `lightning` parameter prefer that invoice; + /// otherwise their on-chain address is returned without query parameters. + public static func normalizedDestination(from payload: String) -> String { + let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + guard let separator = trimmed.firstIndex(of: ":") else { + return trimmed + } + + let scheme = trimmed[.. String { + value.removingPercentEncoding ?? value + } +} + +private extension String { + var removingLeadingDoubleSlash: String { + hasPrefix("//") ? String(dropFirst(2)) : self + } +} diff --git a/ios/WalletKit/Sources/WalletKit/WalletClient.swift b/ios/WalletKit/Sources/WalletKit/WalletClient.swift index 8123ca4..cf61209 100644 --- a/ios/WalletKit/Sources/WalletKit/WalletClient.swift +++ b/ios/WalletKit/Sources/WalletKit/WalletClient.swift @@ -10,10 +10,30 @@ import Wavewalletdk #endif /// Thrown when an embedded-wallet call fails. Wraps the Go-side error. -public struct WalletError: Error, Sendable { +public struct WalletError: LocalizedError, CustomStringConvertible, Sendable { + private static let receiveOutcomeUncertainPrefix = + "receive outcome uncertain; reconcile activity before retrying" + public let message: String init(_ error: Error) { self.message = (error as NSError).localizedDescription } init(message: String) { self.message = message } + + public var errorDescription: String? { message } + public var description: String { message } + + /// Whether the native binding ended the request at its operation-specific + /// deadline. The outcome of a state-creating call may still be uncertain. + public var isDeadlineExceeded: Bool { + let normalized = message.lowercased() + return normalized.contains("deadline exceeded") || + normalized.contains("timed out") + } + + /// Whether a receive may have become durable before cancellation. Reconcile + /// Activity before asking the wallet to create another invoice. + public var isReceiveOutcomeUncertain: Bool { + message.lowercased().hasPrefix(Self.receiveOutcomeUncertainPrefix) + } } /// An idiomatic Swift facade over the gomobile bindings. @@ -81,11 +101,19 @@ public actor WalletClient { return try decode(await bg { try Bindings.unlockWallet(body) }) } - /// Open a Lightning invoice to receive `amountSat` into the wallet. Share - /// the returned invoice with the payer; the matching entry shows up on the - /// activity stream as it is paid. - public func receiveLightning(amountSat: Int64, memo: String = "") async throws -> ReceiveResult { - let body = try encoder.encode(ReceiveReq(amountSat: amountSat, memo: memo)) + /// Open a Lightning invoice to receive `amountSat` into the wallet. The + /// native deadline bounds a stale mobile transport but does not prove a + /// receive was absent; reconcile Activity before retrying after timeout. + public func receiveLightning( + amountSat: Int64, + memo: String = "", + timeoutSeconds: Int64 = 20 + ) async throws -> ReceiveResult { + let body = try encoder.encode(ReceiveReq( + amountSat: amountSat, + memo: memo, + timeoutSeconds: timeoutSeconds + )) return try decode(await bg { try Bindings.receive(body) }) } @@ -274,9 +302,11 @@ private struct SubscribeReq: Encodable { private struct ReceiveReq: Encodable { let amountSat: Int64 let memo: String + let timeoutSeconds: Int64 enum CodingKeys: String, CodingKey { case amountSat = "AmountSat" case memo = "Memo" + case timeoutSeconds = "TimeoutSeconds" } } diff --git a/ios/WalletKit/Sources/WalletKit/WalletConfig.swift b/ios/WalletKit/Sources/WalletKit/WalletConfig.swift index fc7d37c..47f2c2a 100644 --- a/ios/WalletKit/Sources/WalletKit/WalletConfig.swift +++ b/ios/WalletKit/Sources/WalletKit/WalletConfig.swift @@ -1,5 +1,19 @@ import Foundation +/// Bitcoin networks supported by the embedded Wavelength wallet. +public enum WalletNetwork: String, CaseIterable, Codable, Sendable { + case signet + case testnet + case mainnet + case regtest +} + +/// Wire protocol used for an Ark operator or swap service endpoint. +public enum WalletTransport: String, Codable, Sendable { + case grpc + case rest +} + // WalletConfig is the typed mirror of the Go facade's JSON config. Optional // properties that are nil are omitted by JSONEncoder, so the daemon's build-tag // defaults fill in the rest. The CodingKeys match the Go json tags (snake_case). @@ -9,11 +23,15 @@ public struct WalletConfig: Encodable, Sendable { public var network: String public var serverAddress: String? public var serverTransport: String? + public var serverTLSCertPath: String? public var serverInsecure: Bool? public var walletType: String? public var walletEsploraURL: String? public var walletPollIntervalSeconds: Int64? public var swapServerAddress: String? + public var swapServerTransport: String? + public var swapServerTLSCertPath: String? + public var swapServerInsecure: Bool? public var debugLevel: String? public var allowMainnet: Bool? @@ -22,37 +40,175 @@ public struct WalletConfig: Encodable, Sendable { case network = "network" case serverAddress = "server_address" case serverTransport = "server_transport" + case serverTLSCertPath = "server_tls_cert_path" case serverInsecure = "server_insecure" case walletType = "wallet_type" case walletEsploraURL = "wallet_esplora_url" case walletPollIntervalSeconds = "wallet_poll_interval_seconds" case swapServerAddress = "swap_server_address" + case swapServerTransport = "swap_server_transport" + case swapServerTLSCertPath = "swap_server_tls_cert_path" + case swapServerInsecure = "swap_server_insecure" case debugLevel = "debug_level" case allowMainnet = "allow_mainnet" } - /// A signet config for the lightweight (Esplora-backed) wallet. Endpoints - /// default to Lightning Labs' public signet deployment; pass empty strings - /// to run sync-only without an operator. + /// A signet config for the lightweight (Esplora-backed) wallet. Empty + /// endpoints defer to the current defaults compiled into Wavelength. public static func signet( dataDir: String, - esploraURL: String = "https://mempool.space/signet/api", - operatorAddress: String = "arkd-signet.testnet.lightningcluster.com:443", - swapServerAddress: String = "swapd-signet.testnet.lightningcluster.com:443" + esploraURL: String = "", + operatorAddress: String = "", + swapServerAddress: String = "", + operatorTransport: WalletTransport? = nil, + swapTransport: WalletTransport? = nil, + operatorTLSCertPath: String = "", + swapTLSCertPath: String = "" + ) -> WalletConfig { + network( + .signet, + dataDir: dataDir, + esploraURL: esploraURL, + operatorAddress: operatorAddress, + swapServerAddress: swapServerAddress, + operatorTransport: operatorTransport, + swapTransport: swapTransport, + operatorTLSCertPath: operatorTLSCertPath, + swapTLSCertPath: swapTLSCertPath + ) + } + + /// A testnet3 config. Empty endpoint values deliberately defer to the + /// defaults compiled into the Wavelength binding so a host app does not + /// freeze infrastructure addresses at build time. + public static func testnet( + dataDir: String, + esploraURL: String = "", + operatorAddress: String = "", + swapServerAddress: String = "", + operatorTransport: WalletTransport? = nil, + swapTransport: WalletTransport? = nil, + operatorTLSCertPath: String = "", + swapTLSCertPath: String = "" + ) -> WalletConfig { + network( + .testnet, + dataDir: dataDir, + esploraURL: esploraURL, + operatorAddress: operatorAddress, + swapServerAddress: swapServerAddress, + operatorTransport: operatorTransport, + swapTransport: swapTransport, + operatorTLSCertPath: operatorTLSCertPath, + swapTLSCertPath: swapTLSCertPath + ) + } + + /// A mainnet config. Wavelength requires the explicit `allow_mainnet` + /// opt-in. Mainnet has no bundled public Ark or swap deployment, so wallet + /// apps should provide operator and swap endpoints before enabling + /// off-chain operations. On-chain sync can use the daemon's default + /// Esplora endpoint when `esploraURL` is empty. + public static func mainnet( + dataDir: String, + esploraURL: String = "", + operatorAddress: String = "", + swapServerAddress: String = "", + operatorTransport: WalletTransport? = nil, + swapTransport: WalletTransport? = nil, + operatorTLSCertPath: String = "", + swapTLSCertPath: String = "" + ) -> WalletConfig { + network( + .mainnet, + dataDir: dataDir, + esploraURL: esploraURL, + operatorAddress: operatorAddress, + swapServerAddress: swapServerAddress, + operatorTransport: operatorTransport, + swapTransport: swapTransport, + operatorTLSCertPath: operatorTLSCertPath, + swapTLSCertPath: swapTLSCertPath + ) + } + + /// Build a lightweight-wallet configuration for one supported network. + public static func network( + _ network: WalletNetwork, + dataDir: String, + esploraURL: String = "", + operatorAddress: String = "", + swapServerAddress: String = "", + operatorTransport: WalletTransport? = nil, + swapTransport: WalletTransport? = nil, + operatorTLSCertPath: String = "", + swapTLSCertPath: String = "" ) -> WalletConfig { - WalletConfig( + let resolvedOperatorTransport = operatorTransport ?? .inferred(from: operatorAddress) + let resolvedSwapTransport = swapTransport ?? .inferred(from: swapServerAddress) + + return WalletConfig( dataDir: dataDir, - network: "signet", - // The operator and swap endpoints terminate TLS at :443, so they - // are not insecure; leave the flag unset. - serverAddress: operatorAddress.isEmpty ? nil : operatorAddress, - serverTransport: "grpc", - serverInsecure: nil, + network: network.rawValue, + serverAddress: operatorAddress.nilIfEmpty, + serverTransport: resolvedOperatorTransport.rawValue, + serverTLSCertPath: operatorTLSCertPath.nilIfEmpty, + serverInsecure: .insecureValue( + network: network, + address: operatorAddress, + transport: resolvedOperatorTransport, + tlsCertPath: operatorTLSCertPath + ), walletType: "lwwallet", - walletEsploraURL: esploraURL, - walletPollIntervalSeconds: 30, - swapServerAddress: swapServerAddress.isEmpty ? nil : swapServerAddress, - debugLevel: "info" + walletEsploraURL: esploraURL.nilIfEmpty, + walletPollIntervalSeconds: network.esploraPollIntervalSeconds, + swapServerAddress: swapServerAddress.nilIfEmpty, + swapServerTransport: resolvedSwapTransport.rawValue, + swapServerTLSCertPath: swapTLSCertPath.nilIfEmpty, + swapServerInsecure: .insecureValue( + network: network, + address: swapServerAddress, + transport: resolvedSwapTransport, + tlsCertPath: swapTLSCertPath + ), + debugLevel: "info", + allowMainnet: network == .mainnet ) } } + +private extension WalletNetwork { + var esploraPollIntervalSeconds: Int64 { + switch self { + case .regtest: return 1 + case .signet, .testnet: return 10 + case .mainnet: return 30 + } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} + +private extension WalletTransport { + static func inferred(from address: String) -> WalletTransport { + let value = address.lowercased() + return value.hasPrefix("http://") || value.hasPrefix("https://") ? .rest : .grpc + } +} + +private extension Optional where Wrapped == Bool { + static func insecureValue( + network: WalletNetwork, + address: String, + transport: WalletTransport, + tlsCertPath: String + ) -> Bool? { + guard network == .regtest, tlsCertPath.isEmpty else { return nil } + if transport == .rest { + return address.lowercased().hasPrefix("http://") ? true : nil + } + return true + } +} diff --git a/scripts/fetch-aar.sh b/scripts/fetch-aar.sh index ed8e443..8bc6788 100755 --- a/scripts/fetch-aar.sh +++ b/scripts/fetch-aar.sh @@ -66,9 +66,8 @@ if [[ -n "${WAVELENGTH_DIR:-}" ]]; then exit 0 fi -# Default: download the binding from the GitHub release. wavelength is a -# private repo, so this needs a gh CLI authenticated to an account with read -# access (gh auth login). +# Default: download the binding from the GitHub release. Use an authenticated +# gh CLI so repository access and release-asset redirects work consistently. if ! command -v gh >/dev/null 2>&1; then echo "error: gh CLI not found; install it and run 'gh auth login', or set" >&2 echo " WAVELENGTH_DIR to build from a local wavelength checkout." >&2 diff --git a/scripts/fetch-xcframework.sh b/scripts/fetch-xcframework.sh index 0457c14..a4c1a7d 100755 --- a/scripts/fetch-xcframework.sh +++ b/scripts/fetch-xcframework.sh @@ -54,8 +54,8 @@ fi # Default: download the packaged binding from the GitHub release and unpack it. # The release asset is a tarball (Wavewalletdk.xcframework.tar.gz) since an -# .xcframework is a directory. wavelength is a private repo, so this needs a gh -# CLI authenticated to an account with read access (gh auth login). +# .xcframework is a directory. Use an authenticated gh CLI so repository +# access and release-asset redirects work consistently. if ! command -v gh >/dev/null 2>&1; then echo "error: gh CLI not found; install it and run 'gh auth login', or set" >&2 echo " WAVELENGTH_DIR to build from a local wavelength checkout." >&2 diff --git a/scripts/observe-ios-device.sh b/scripts/observe-ios-device.sh new file mode 100755 index 0000000..22479c1 --- /dev/null +++ b/scripts/observe-ios-device.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Relaunch the installed sample app on a physical device and keep its stdout +# and stderr attached to this terminal. Go mobile logs are emitted to stdout, +# so this captures the embedded daemon and the Swift app in one live stream. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUNDLE_ID="${BUNDLE_ID:-engineering.lightning.wavelength.wallet}" + +device_udid="$("${REPO_ROOT}/scripts/select-ios-device.sh")" + +echo "==> using physical device ${device_udid}" +echo "==> launching ${BUNDLE_ID} with a live console" +echo "==> press Ctrl-C to detach; the app remains installed" + +exec xcrun devicectl device process launch \ + --console \ + --terminate-existing \ + --device "${device_udid}" \ + "${BUNDLE_ID}" diff --git a/scripts/run-ios-sample.sh b/scripts/run-ios-sample.sh index d3ef45f..7c09f5e 100755 --- a/scripts/run-ios-sample.sh +++ b/scripts/run-ios-sample.sh @@ -14,8 +14,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SAMPLE_DIR="${REPO_ROOT}/ios/Sample" -BUNDLE_ID="engineering.lightning.wavewalletdk.sample" -SCHEME="WavewalletdkSample" +BUNDLE_ID="engineering.lightning.wavelength.wallet" +SCHEME="Wavelength" # 1. Stage the bindings. if [[ ! -d "${REPO_ROOT}/ios/WalletKit/Frameworks/Wavewalletdk.xcframework" ]]; then @@ -28,15 +28,16 @@ echo "==> xcodegen generate" ( cd "${SAMPLE_DIR}" && xcodegen generate ) # 3. Pick (or create) and boot a simulator. -device_udid="$(xcrun simctl list devices available | grep -oE 'iPhone[^(]*\([-0-9A-F]+\)' | head -1 | grep -oE '[-0-9A-F]{36}' || true)" -if [[ -z "${device_udid}" ]]; then - runtime="$(xcrun simctl list runtimes | grep -oE 'com.apple.CoreSimulator.SimRuntime.iOS[-0-9]+' | head -1)" - devtype="$(xcrun simctl list devicetypes | grep -oE 'com.apple.CoreSimulator.SimDeviceType.iPhone[-0-9A-Za-z]+' | head -1)" - echo "==> creating simulator (${devtype} / ${runtime})" - device_udid="$(xcrun simctl create "wavelength-mobile-iphone" "${devtype}" "${runtime}")" +device_udid="$("${REPO_ROOT}/scripts/select-ios-simulator.sh")" +echo "==> using simulator ${device_udid}" + +# `simctl boot` starts the virtual device but intentionally does not show the +# macOS Simulator window. Interactive run targets should open and foreground +# the GUI; set OPEN_SIMULATOR=0 when a headless launch is preferable. +if [[ "${OPEN_SIMULATOR:-1}" == "1" ]]; then + echo "==> opening Simulator.app" + open -a Simulator --args -CurrentDeviceUDID "${device_udid}" fi -echo "==> booting ${device_udid}" -xcrun simctl bootstatus "${device_udid}" -b || xcrun simctl boot "${device_udid}" || true # 4. Build, install, launch. echo "==> xcodebuild" @@ -50,6 +51,25 @@ xcodebuild \ app="${SAMPLE_DIR}/DerivedData/Build/Products/Debug-iphonesimulator/${SCHEME}.app" echo "==> installing ${app}" xcrun simctl install "${device_udid}" "${app}" + +# simctl only forwards environment variables carrying its SIMCTL_CHILD_ +# prefix. Keep normal launches clean, while allowing an external regtest +# environment to configure the app without modifying persisted app settings. +for name in \ + WAVELENGTH_REGTEST \ + WAVELENGTH_AUTOCREATE \ + WAVELENGTH_OPERATOR_ADDRESS \ + WAVELENGTH_SWAP_ADDRESS \ + WAVELENGTH_ESPLORA_URL +do + if [[ -n "${!name:-}" ]]; then + export "SIMCTL_CHILD_${name}=${!name}" + fi +done xcrun simctl launch "${device_udid}" "${BUNDLE_ID}" +if [[ "${OPEN_SIMULATOR:-1}" == "1" ]]; then + open -a Simulator +fi + echo "==> running. Screenshot with: xcrun simctl io ${device_udid} screenshot ui.png" diff --git a/scripts/select-ios-device.sh b/scripts/select-ios-device.sh new file mode 100755 index 0000000..b8f2aeb --- /dev/null +++ b/scripts/select-ios-device.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +# Select a connected physical iOS device. A caller can pin a phone by setting +# DEVICE_UDID; otherwise the first connected device reported by devicectl is +# used. + +set -euo pipefail + +requested_udid="${DEVICE_UDID:-}" +devices="$(xcrun devicectl list devices)" + +extract_udid() { + grep -oE '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' \ + | head -1 +} + +if [[ -n "${requested_udid}" ]]; then + device_line="$(grep -F "${requested_udid}" <<<"${devices}" || true)" + if [[ -z "${device_line}" || "${device_line}" != *" connected "* ]]; then + echo "error: DEVICE_UDID=${requested_udid} is not connected" >&2 + echo "Connect and unlock the phone, then run 'make device'." >&2 + exit 1 + fi + + device_udid="$(extract_udid <<<"${device_line}")" +else + device_udid="$(grep ' connected ' <<<"${devices}" \ + | extract_udid || true)" +fi + +if [[ -z "${device_udid:-}" ]]; then + echo "error: no connected physical iOS device found" >&2 + echo "Connect and unlock the phone, trust this Mac, and enable Developer Mode." >&2 + exit 1 +fi + +printf '%s\n' "${device_udid}" diff --git a/scripts/select-ios-simulator.sh b/scripts/select-ios-simulator.sh new file mode 100755 index 0000000..789d454 --- /dev/null +++ b/scripts/select-ios-simulator.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +# Select and boot an iPhone Simulator. A caller can pin a device by setting +# SIMULATOR_UDID; otherwise a booted iPhone is preferred, followed by the first +# available iPhone. A device is created only when no iPhone Simulator exists. + +set -euo pipefail + +requested_udid="${SIMULATOR_UDID:-}" +devices="$(xcrun simctl list devices available)" + +extract_udid() { + grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' \ + | head -1 +} + +if [[ -n "${requested_udid}" ]]; then + if ! grep -q "${requested_udid}" <<<"${devices}"; then + echo "error: SIMULATOR_UDID=${requested_udid} is not an available Simulator" >&2 + echo "Run 'make simulator' without SIMULATOR_UDID to select one automatically." >&2 + exit 1 + fi + device_udid="${requested_udid}" +else + device_udid="$(grep 'iPhone.*(Booted)' <<<"${devices}" | extract_udid || true)" + if [[ -z "${device_udid}" ]]; then + device_udid="$(grep 'iPhone' <<<"${devices}" | extract_udid || true)" + fi +fi + +if [[ -z "${device_udid:-}" ]]; then + runtime="$(xcrun simctl list runtimes available \ + | grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.iOS[-0-9]+' \ + | tail -1)" + devtype="$(xcrun simctl list devicetypes \ + | grep 'iPhone' \ + | grep -oE 'com\.apple\.CoreSimulator\.SimDeviceType\.iPhone[-0-9A-Za-z]+' \ + | tail -1)" + + if [[ -z "${runtime}" || -z "${devtype}" ]]; then + echo "error: no iPhone Simulator or usable iOS runtime is installed" >&2 + echo "Install a runtime with: xcodebuild -downloadPlatform iOS" >&2 + exit 1 + fi + + echo "Creating Wavelength iPhone Simulator (${devtype}, ${runtime})" >&2 + device_udid="$(xcrun simctl create \ + 'wavelength-mobile-iphone' "${devtype}" "${runtime}")" +fi + +state="$(xcrun simctl list devices | grep "${device_udid}" || true)" +if [[ "${state}" != *"(Booted)"* ]]; then + echo "Booting iPhone Simulator ${device_udid}" >&2 + xcrun simctl boot "${device_udid}" +fi +xcrun simctl bootstatus "${device_udid}" -b >&2 + +printf '%s\n' "${device_udid}"