diff --git a/docker/bitcoin-cli b/docker/bitcoin-cli index 6c86d0c..6700969 100755 --- a/docker/bitcoin-cli +++ b/docker/bitcoin-cli @@ -28,6 +28,8 @@ Commands: getInvoice Get a new BIP21 URI with a bech32 address LND: getinfo Show LND node info (for connectivity debugging) + openchannel [amount] Open channel from LND to node (default: 500000 sats) + payinvoice [amount] Pay a Lightning invoice via LND holdinvoice [amount] [-m memo] Create a hold invoice settleinvoice Reveal a preimage and use it to settle the corresponding invoice cancelinvoice Cancels a currently open invoice @@ -196,6 +198,127 @@ if [[ "$command" = "getinfo" ]]; then exit fi +# Open channel from LND to a node +if [[ "$command" = "openchannel" ]]; then + shift + + node_id="${1:-}" + amount="${2:-500000}" + + if [ -z "$node_id" ]; then + echo "Usage: $CLI_NAME openchannel [amount_sats]" + echo "" + echo " node_id: app's Lightning node ID (Settings > Advanced > Lightning Node Info)" + echo " amount: channel size in sats (default: 500000)" + exit 1 + fi + + # Check peer connection + echo "→ Checking peer connection..." + peer_count=$("${LNCLI_CMD[@]}" listpeers 2>/dev/null | jq "[.peers[] | select(.pub_key==\"$node_id\")] | length") + + if [ "$peer_count" = "0" ]; then + lnd_pubkey=$("${LNCLI_CMD[@]}" getinfo 2>/dev/null | jq -r '.identity_pubkey') + echo "✗ Node is not connected as a peer." + echo "" + echo " Paste this in the app (Settings > Advanced > Channels > Add Connection):" + echo " ${lnd_pubkey}@0.0.0.0:9735" + echo "" + echo " Then re-run this command." + exit 1 + fi + + echo "✓ Peer connected" + + # Fund LND if needed + balance=$("${LNCLI_CMD[@]}" walletbalance 2>/dev/null | jq -r '.confirmed_balance') + echo "→ LND on-chain balance: $balance sats" + + if [ "$balance" -lt "$amount" ]; then + echo "→ Funding LND..." + lnd_addr=$("${LNCLI_CMD[@]}" newaddress p2wkh 2>/dev/null | jq -r '.address') + "${BASE_COMMAND[@]}" -named sendtoaddress address="$lnd_addr" amount=1 fee_rate=25 > /dev/null + "${BASE_COMMAND[@]}" -generate 6 > /dev/null + echo "✓ Funded LND with 1 BTC" + sleep 2 + fi + + # Open channel + echo "→ Opening ${amount} sat channel to ${node_id:0:20}..." + result=$("${LNCLI_CMD[@]}" openchannel --node_key "$node_id" --local_amt "$amount" --private 2>&1) || { + echo "✗ Failed: $result" + exit 1 + } + + txid=$(echo "$result" | jq -r '.funding_txid // empty' 2>/dev/null) + if [ -z "$txid" ]; then + echo "✗ Failed: $result" + exit 1 + fi + + echo "✓ Channel opened, funding txid: $txid" + + # Mine and wait + echo "→ Mining 6 blocks..." + "${BASE_COMMAND[@]}" -generate 6 > /dev/null + echo "✓ Mined 6 blocks" + + echo "→ Waiting for channel to become active..." + for i in $(seq 1 30); do + sleep 2 + active=$("${LNCLI_CMD[@]}" listchannels --peer "$node_id" --active_only 2>/dev/null | jq '.channels | length') + if [ "$active" != "0" ]; then + echo "✓ Channel is active!" + break + fi + if [ $((i % 5)) -eq 0 ]; then echo " still waiting... ($i)"; fi + done + + if [ "$active" = "0" ]; then + echo "⚠ Channel not active yet. May need more time or app needs to sync." + fi + + # Summary + echo "" + echo "══════════════════════════════════" + "${LNCLI_CMD[@]}" channelbalance 2>/dev/null | jq -r '" LND outbound: \(.local_balance.sat) sats (can pay app)\n LND inbound: \(.remote_balance.sat) sats (can receive from app)"' + echo "══════════════════════════════════" + exit +fi + +# Pay a Lightning invoice via LND +if [[ "$command" = "payinvoice" ]]; then + shift + + invoice="${1:-}" + amount="${2:-}" + + if [ -z "$invoice" ]; then + echo "Usage: $CLI_NAME payinvoice [amount_sats]" + exit 1 + fi + + if [ -n "$amount" ]; then + echo "→ Paying invoice via LND (${amount} sats)..." + result=$("${LNCLI_CMD[@]}" payinvoice --force --amt "$amount" "$invoice" 2>&1) + else + echo "→ Paying invoice via LND..." + result=$("${LNCLI_CMD[@]}" payinvoice --force "$invoice" 2>&1) + fi + + status=$(echo "$result" | grep -i "status" | head -1) + if echo "$result" | grep -qi "SUCCEEDED"; then + echo "✓ Payment succeeded" + echo "$result" | grep -i "payment_hash\|payment_preimage" | head -2 + else + echo "✗ Payment failed" + echo "$result" + exit 1 + fi + + exit +fi + # Create a hold invoice (LND) if [[ "$command" = "holdinvoice" ]]; then shift diff --git a/docs/repro-channel-monitor-desync.md b/docs/repro-channel-monitor-desync.md new file mode 100644 index 0000000..3aa0ecf --- /dev/null +++ b/docs/repro-channel-monitor-desync.md @@ -0,0 +1,227 @@ +# ChannelMonitor Desync: Repro, Recovery & Test Plan + +Related issues: +- [#847 (bitkit-android)](https://github.com/synonymdev/bitkit-android/issues/847) +- iOS support ticket (user logs from 2026-03-18) + +Fix branches: +- **iOS**: `fix/stale-monitor-recovery-release` +- **Android**: `fix/stale-monitor-recovery-v2` + +## Summary + +Build 182 (v2.1.0) introduced `fetchOrphanedChannelMonitorsIfNeeded` which fetches old channel monitors from the RN remote backup on every startup (when `isChannelRecoveryChecked` is false). If the wallet was migrated from RN to native and used on native for enough LN payments, the RN backup monitor is stale. Injecting it causes a fatal ChannelManager/ChannelMonitor update_id mismatch and LDK refuses to start. + +## Root Cause + +On v2.1.0 startup: +1. `fetchOrphanedChannelMonitorsIfNeeded` fetches stale channel monitor from RN backup server +2. Injects it via `setChannelDataMigration` with `channelManager: nil` (monitors only) +3. ldk-node persists the stale monitor to VSS/local storage +4. LDK loads ChannelManager (advanced) against stale ChannelMonitor → fatal mismatch +5. Node refuses to start: `"A ChannelMonitor is stale compared to the current ChannelManager!"` + +## Error Signature + +``` +A ChannelMonitor is stale compared to the current ChannelManager! +The ChannelMonitor for channel is at update_id with update_id through in-flight +but the ChannelManager is at update_id . +Failed to read channel manager from store: Value would be dangerous to continue execution with +``` + +In app logs: +``` +Running pre-startup channel monitor recovery check +Found 1 monitors on RN backup for pre-startup recovery +Applied channel migration: 1 monitors +Migrating channel monitor: +A ChannelMonitor is stale compared to the current ChannelManager! +Read failed [Failed to read from store.] +Failed to start wallet +``` + +--- + +## Repro Case #1: Blocktank channel (staging regtest) + +Reproduces the bug using a Blocktank LSP channel opened via "transfer to spending". + +### Prerequisites + +- **Bitkit v1.1.6** (React Native) iOS or Android build +- **Bitkit v2.0.6** (native iOS) or **v2.0.3** (native Android) build +- **Bitkit v2.1.0** (native) iOS or Android build — the buggy version +- **Staging regtest** Blocktank API access (`BACKEND=regtest`) +- **Appium** running locally for the automated payment step + +### Steps + +1. Install v1.1.6 (RN), create wallet, fund on-chain, open Lightning channel (transfer to spending), make 1 LN payment +2. Install v2.0.6 (iOS) or v2.0.3 (Android) **over** RN app — migration runs automatically +3. Make 21+ Lightning payments on native: + ```bash + # iOS + SIMULATOR_OS_VERSION=26.0 BACKEND=regtest npx wdio wdio.no-install.conf.ts + # Android + PLATFORM=android BACKEND=regtest npx wdio wdio.no-install.conf.ts + ``` +4. Install v2.1.0 **over** native app → app fails to start LN node (see error signature above) + +--- + +## Repro Case #2: 3rd-party channel (local docker) + +Reproduces the bug using a manually opened channel to the local docker LND node. + +### Prerequisites + +- **bitkit-docker** running locally (Bitcoin, Electrum, LND, backup server) +- Local regtest builds for each version (see Build Notes below) +- **Appium** running locally for the automated payment step + +### Build Notes + +RN v1.1.6 local builds use `.env.test.template` (regtest + localhost Electrum). For release builds, `react-native-dotenv` reads `.env.production`, so that file must be overwritten with the local regtest config. + +**Critical**: The RN app's `.env.production` must point the backup server to **staging** (not localhost), because the native apps have `rnBackupServerHost` hardcoded to staging. If the RN app pushes to `127.0.0.1:3003` but the native app queries `bitkit.stag0.blocktank.to`, it will never find the channel monitors and the bug won't trigger. + +In `.env.production` for the RN v1.1.6 build, set: +``` +BACKUPS_SERVER_HOST=https://bitkit.stag0.blocktank.to/backups-ldk +BACKUPS_SERVER_PUBKEY=02c03b8b8c1b5500b622646867d99bf91676fac0f38e2182c91a9ff0d053a21d6d +``` + +All other settings (Electrum, network, etc.) stay local. + +### Steps + +#### 1. RN wallet setup (v1.1.6) + +1. Install the local RN build on simulator/emulator +2. Create a new wallet +3. Fund the wallet on-chain: + ```bash + cd docker + ./bitcoin-cli send 0.01
+ ./bitcoin-cli mine 6 + ``` +4. In the app, go to **Settings > Advanced > Channels > + > Fund Custom > Manual** and enter the local LND connection (get the node ID from `./bitcoin-cli getinfo`): + - Node ID: LND's pubkey + - Host: `0.0.0.0` + - Port: `9735` +5. Set amount (e.g. 50,000 sats) and confirm the channel open +6. Mine blocks: `./bitcoin-cli mine 6` +7. Wait for the channel to be active + +#### 2. Open channel from LND to the app + +The app's channel has all balance on the app side. LND needs outbound liquidity to pay invoices to the app. Get the app's node ID from **Settings > Advanced > Lightning Node Info**, then: + +```bash +./bitcoin-cli openchannel 500000 +``` + +Verify with a test payment: +```bash +./bitcoin-cli payinvoice 10 +``` + +#### 3. Migrate to native (v2.0.6 iOS / v2.0.3 Android) + +1. Install native build **over** RN app (upgrade, not clean install) +2. Wait for migration and sync +3. Verify the channel is active and LND is connected as a peer + +#### 4. Make 30 Lightning payments on native + +```bash +# iOS +PAYMENT_COUNT=30 SIMULATOR_OS_VERSION=26.0 npx wdio wdio.no-install.conf.ts +# Android +PAYMENT_COUNT=30 PLATFORM=android npx wdio wdio.no-install.conf.ts +``` + +> **Note**: The peer connection drops on app restarts. Re-paste the LND URI if needed before running the test. + +#### 5. Upgrade to v2.1.0 + +Install v2.1.0 **over** the native app → app fails to start LN node (see error signature above). + +--- + +## Recovery: Upgrade to v2.1.2 + +Upgrading from a broken v2.1.0 wallet to v2.1.2 (fix candidate) recovers the wallet. Channels are healed and LN transactions work after recovery. + +Fix branches: +- **iOS**: `fix/stale-monitor-recovery-release` +- **Android**: `fix/stale-monitor-recovery-v2` + +### Steps + +1. Start with a broken v2.1.0 wallet (reproduced via either case above) +2. Install v2.1.2 **over** v2.1.0 +3. Launch the app +4. Verify: node starts, channels are active, LN payments work + +### Post-recovery channel closure + +Whether healed channels should be closed after recovery is under discussion. For testing: verify wallet is operational after recovery regardless of channel closure outcome. On-chain balance should be intact even if healed channels are subsequently closed. + +--- + +## Test Plan + +Matrix of upgrade/recovery scenarios to validate v2.1.2. Each scenario should be tested for both channel types where marked. + +### Blocktank channel (staging regtest) + +| # | Scenario | Result | +|---|----------|--------| +| B1 | v2.0.6 (wallet with 21+ payment gap) → v2.1.0 → confirm broken | Reproduces | +| B2 | Restore broken v2.1.0 wallet into v2.1.2 (clean install + restore) | ✅ Recovered | +| B3 | Update broken v2.1.0 wallet to v2.1.2 (in-place upgrade) | ✅ Recovered | +| B4 | v2.0.6 (wallet with gap) → v2.1.2 (skip v2.1.0) | ✅ No issues | +| B5 | v2.0.6 (wallet with gap) → v2.1.1 → v2.1.2 | ✅ Recovered | +| B6 | v2.1.0 healthy wallet (no gap) → v2.1.2 (regression check) | ✅ No issues | +| B7 | v2.1.0 broken wallet + 600 blocks mined → v2.1.2 (stale chain state) | ✅ Recovered | + +### 3rd-party channel (local docker) + +| # | Scenario | Result | +|---|----------|--------| +| T1 | v2.0.6 (wallet with 30+ payment gap) → v2.1.0 → confirm broken | Reproduces | +| T2 | Update broken v2.1.0 wallet to v2.1.2 (in-place upgrade) | ✅ Recovered | +| T3 | v2.0.6 (wallet with gap) → v2.1.2 (skip v2.1.0) | ✅ No issues | +| T4 | v2.1.0 healthy wallet (no gap) → v2.1.2 (regression check) | ✅ No issues | +| T5 | v2.1.0 broken wallet + 600 blocks mined → v2.1.2 (stale chain state) | ✅ Recovered | + +### Version reference + +| Version | iOS branch | Android branch | +|---------|-----------|---------------| +| v1.1.6 | tag `v1.1.6` (RN) | tag `v1.1.6` (RN) | +| v2.0.6 | `chore/e2e-updater-url` | — | +| v2.0.3 | — | `chore/e2e-updater-url` | +| v2.1.0 | build 182 | build 182 | +| v2.1.2 (fix) | `fix/stale-monitor-recovery-release` | `fix/stale-monitor-recovery-v2` | + +--- + +## Key Details + +- **21 payments on native is the minimum** for Blocktank channels. Each payment generates ~3-5 update_id increments. LDK can recover small gaps (~10 updates) by replaying counterparty commitment updates. +- **RN payments don't need to be many** — just enough to establish the channel and create the RN backup. +- The bug is in `fetchOrphanedChannelMonitorsIfNeeded` in `WalletViewModel.swift` (iOS) / `WalletViewModel.kt` (Android). It unconditionally injects old RN monitors without checking compatibility with the current ChannelManager. +- **RN backup server mismatch**: The RN app's backup server is configurable via `.env`, but the native apps hardcode `rnBackupServerHost` to staging. For local docker repro, the RN build must push to the same staging server the native apps query. + +## Files + +| File | Purpose | +|------|---------| +| `test/specs/receive-ln-payments.e2e.ts` | Automated spec to receive N Lightning payments | +| `wdio.no-install.conf.ts` | WDIO config that attaches to existing app (no reinstall) | +| `docker/bitcoin-cli` | Local docker CLI with `openchannel`, `payinvoice`, `mine`, `send` commands | +| `scripts/pay-lightning-address.sh` | Shell script to pay BOLT11/BIP21/LN address via Blocktank | +| `scripts/pay-lightning-address-loop.sh` | Shell script to send N payments to a Lightning address | diff --git a/scripts/pay-lightning-address.sh b/scripts/pay-lightning-address.sh new file mode 100755 index 0000000..f02f28f --- /dev/null +++ b/scripts/pay-lightning-address.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# +# Pay a Lightning invoice via Blocktank staging regtest API +# +# Accepts: +# BOLT11 invoice: lnbcrt1p5m4tld... +# Lightning address: satoshi@domain.com +# BIP21 URI: bitcoin:bcrt1q...?lightning=lnbcrt1p5m4tld... +# +# Usage: +# ./scripts/pay-lightning-address.sh [amount_sats] +# +# Examples: +# ./scripts/pay-lightning-address.sh lnbcrt1p5m4tld... +# ./scripts/pay-lightning-address.sh 'bitcoin:bcrt1q...?lightning=lnbcrt1p5m4tld...' +# ./scripts/pay-lightning-address.sh satoshi@localhost:3003 500 +# +# Environment: +# BLOCKTANK_URL → override API base (default: staging) +# LNURL_SCHEME → http or https for LN address resolution (default: auto) + +set -e + +INPUT="$1" +AMOUNT_SATS="${2:-100}" +BLOCKTANK_URL="${BLOCKTANK_URL:-https://api.stag0.blocktank.to/blocktank/api/v2}" + +if [ -z "$INPUT" ]; then + echo "Usage: $0 [amount_sats]" + echo "" + echo " BOLT11: $0 lnbcrt1p5m4tld..." + echo " BIP21: $0 'bitcoin:bcrt1q...?lightning=lnbcrt1p5m4tld...'" + echo " LN addr: $0 satoshi@localhost:3003 500" + exit 1 +fi + +INVOICE="" + +# --- Detect input type --- + +# BIP21 URI with lightning= parameter +if echo "$INPUT" | grep -qi "^bitcoin:.*lightning="; then + INVOICE=$(echo "$INPUT" | sed -n 's/.*[?&]lightning=\([^&]*\).*/\1/p' | tr -d '[:space:]') + if [ -z "$INVOICE" ]; then + echo "✗ Could not extract lightning invoice from BIP21 URI" + exit 1 + fi + echo "→ Extracted invoice from BIP21 URI (${#INVOICE} chars)" + +# Raw BOLT11 invoice +elif echo "$INPUT" | grep -qiE "^ln(bc|tb|tbs|bcrt)"; then + INVOICE=$(echo "$INPUT" | tr -d '[:space:]') + echo "→ Using BOLT11 invoice (${#INVOICE} chars)" + +# Lightning address (user@domain) +elif echo "$INPUT" | grep -q "@"; then + if ! command -v jq &> /dev/null; then + echo "✗ jq required for LN address. Install: brew install jq" + exit 1 + fi + + USERNAME="${INPUT%%@*}" + DOMAIN="${INPUT#*@}" + + if [ -n "$LNURL_SCHEME" ]; then + SCHEME="$LNURL_SCHEME" + elif echo "$DOMAIN" | grep -qE "^(localhost|127\.0\.0\.1)"; then + SCHEME="http" + else + SCHEME="https" + fi + + AMOUNT_MSATS=$((AMOUNT_SATS * 1000)) + echo "→ Resolving ${USERNAME}@${DOMAIN}..." + + LNURL_RESPONSE=$(curl -sf "${SCHEME}://${DOMAIN}/.well-known/lnurlp/${USERNAME}") + if [ $? -ne 0 ]; then + echo "✗ Failed to resolve lightning address" + exit 1 + fi + + CALLBACK=$(echo "$LNURL_RESPONSE" | jq -r '.callback') + MIN_SENDABLE=$(echo "$LNURL_RESPONSE" | jq -r '.minSendable') + MAX_SENDABLE=$(echo "$LNURL_RESPONSE" | jq -r '.maxSendable') + + if [ "$AMOUNT_MSATS" -lt "$MIN_SENDABLE" ] || [ "$AMOUNT_MSATS" -gt "$MAX_SENDABLE" ]; then + echo "✗ ${AMOUNT_SATS} sats out of range [$((MIN_SENDABLE/1000))-$((MAX_SENDABLE/1000))] sats" + exit 1 + fi + + echo "→ Requesting invoice for ${AMOUNT_SATS} sats..." + SEP="?"; echo "$CALLBACK" | grep -q "?" && SEP="&" + INVOICE_RESPONSE=$(curl -sf "${CALLBACK}${SEP}amount=${AMOUNT_MSATS}") + INVOICE=$(echo "$INVOICE_RESPONSE" | jq -r '.pr' | tr -d '[:space:]') + + if [ -z "$INVOICE" ] || [ "$INVOICE" = "null" ]; then + echo "✗ No invoice in callback response" + echo "$INVOICE_RESPONSE" | jq . + exit 1 + fi + echo "→ Got invoice (${#INVOICE} chars)" +else + echo "✗ Unrecognized format. Use: BOLT11, BIP21 URI, or lightning address" + exit 1 +fi + +# --- Pay via Blocktank --- + +BODY="{\"invoice\": \"${INVOICE}\"" +if [ -n "$AMOUNT_SATS" ] && [ "$AMOUNT_SATS" -gt 0 ] 2>/dev/null; then + BODY="${BODY}, \"amountSat\": ${AMOUNT_SATS}" +fi +BODY="${BODY}}" + +echo "→ Paying ${AMOUNT_SATS} sats via Blocktank..." +PAY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${BLOCKTANK_URL}/regtest/channel/pay" \ + -H "Content-Type: application/json" \ + -d "$BODY") + +HTTP_CODE=$(echo "$PAY_RESPONSE" | tail -n1) +BODY=$(echo "$PAY_RESPONSE" | sed '$d') + +if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "✓ Payment sent" + [ -n "$BODY" ] && echo " $BODY" +else + echo "✗ Payment failed: HTTP $HTTP_CODE" + echo " $BODY" + exit 1 +fi diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts index db7246f..728bec8 100644 --- a/test/helpers/constants.ts +++ b/test/helpers/constants.ts @@ -49,12 +49,16 @@ export const blocktankURL = export type LndConfig = { server: string; + restHost: string; + restPort: number; tls: string; macaroonPath: string; }; export const lndConfig: LndConfig = { server: 'localhost:10009', + restHost: 'localhost', + restPort: 8080, tls: `${__dirname}/../../docker/lnd/tls.cert`, macaroonPath: `${__dirname}/../../docker/lnd/data/chain/bitcoin/regtest/admin.macaroon`, }; diff --git a/test/helpers/regtest.ts b/test/helpers/regtest.ts index 7e2b4d0..0e9bc5b 100644 --- a/test/helpers/regtest.ts +++ b/test/helpers/regtest.ts @@ -7,8 +7,10 @@ * Default is 'local' for backwards compatibility with existing tests. */ +import * as fs from 'node:fs'; +import * as https from 'node:https'; import BitcoinJsonRpc from 'bitcoin-json-rpc'; -import { bitcoinURL, blocktankURL, getBackend, type Backend } from './constants'; +import { bitcoinURL, blocktankURL, lndConfig, getBackend, type Backend } from './constants'; export { getBackend, type Backend }; @@ -47,6 +49,65 @@ async function localMineBlocks(count: number): Promise { console.info(`→ [local] Mined ${count} block(s)`); } +function lndRestRequest( + path: string, + body: Record, +): Promise> { + const tlsCert = fs.readFileSync(lndConfig.tls); + const macaroon = fs.readFileSync(lndConfig.macaroonPath).toString('hex'); + const payload = JSON.stringify(body); + + return new Promise((resolve, reject) => { + const req = https.request( + { + hostname: lndConfig.restHost, + port: lndConfig.restPort, + path, + method: 'POST', + ca: tlsCert, + rejectUnauthorized: false, + headers: { + 'Grpc-Metadata-macaroon': macaroon, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { data += chunk.toString(); }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(JSON.parse(data) as Record); + } else { + reject(new Error(`LND REST ${res.statusCode}: ${data}`)); + } + }); + }, + ); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +async function localPayInvoice(invoice: string, amountSat?: number): Promise { + const body: Record = { payment_request: invoice }; + if (amountSat !== undefined) { + body.amt = amountSat; + } + + console.info(`→ [local] Paying invoice via LND REST...`); + const result = await lndRestRequest('/v1/channels/transactions', body); + + if (result.payment_error && result.payment_error !== '') { + throw new Error(`LND payment error: ${result.payment_error}`); + } + + const paymentHash = (result.payment_hash as string) ?? 'unknown'; + console.info(`→ [local] Payment hash: ${paymentHash}`); + return paymentHash; +} + // Blocktank backend (regtest API over HTTPS) async function blocktankDeposit(address: string, amountSat?: number): Promise { @@ -249,16 +310,20 @@ export async function mineBlocks(count: number = 1): Promise { /** * Pays a Lightning invoice on regtest. - * Only available with Blocktank backend (regtest). + * - BACKEND=local: uses the local LND node via REST API + * - BACKEND=regtest: uses Blocktank API * * @param invoice - The BOLT11 invoice to pay * @param amountSat - Amount in satoshis (optional, for amount-less invoices) - * @returns The payment ID + * @returns The payment ID / payment hash */ export async function payInvoice(invoice: string, amountSat?: number): Promise { const backend = getBackend(); - if (backend !== 'regtest') { - throw new Error('payInvoice() is only available with BACKEND=regtest (Blocktank API)'); + if (backend === 'local') { + return localPayInvoice(invoice, amountSat); + } + if (backend === 'regtest') { + return blocktankPayInvoice(invoice, amountSat); } - return blocktankPayInvoice(invoice, amountSat); + throw new Error('payInvoice() is not available with BACKEND=mainnet'); } diff --git a/test/specs/receive-ln-payments.e2e.ts b/test/specs/receive-ln-payments.e2e.ts new file mode 100644 index 0000000..765ee17 --- /dev/null +++ b/test/specs/receive-ln-payments.e2e.ts @@ -0,0 +1,92 @@ +/** + * Utility spec: receive multiple Lightning payments on an already-installed app. + * + * IMPORTANT: This spec attaches to the app already on the simulator — it does NOT + * reinstall or reset. Use this when you have a specific version prepared and just + * want to pump LN payments into it. + * + * Usage: + * BACKEND=regtest npm run e2e:ios -- --spec ./test/specs/receive-ln-payments.e2e.ts + * + * Environment: + * PAYMENT_COUNT — number of payments to receive (default: 21) + * PAYMENT_AMOUNT — amount per payment in sats (default: 10) + */ + +import { + acknowledgeReceivedPayment, + elementById, + getUriFromQRCode, + sleep, + swipeFullScreen, + tap, +} from '../helpers/actions'; +import { payInvoice } from '../helpers/regtest'; +import { getAppId } from '../helpers/constants'; + +const PAYMENT_COUNT = Number(process.env.PAYMENT_COUNT || '21'); +const PAYMENT_AMOUNT = Number(process.env.PAYMENT_AMOUNT || '10'); + +function extractLightningInvoice(uri: string): string { + const query = uri.split('?')[1] ?? ''; + const params = new URLSearchParams(query); + const ln = params.get('lightning'); + if (!ln) { + throw new Error(`No lightning invoice found in URI: ${uri}`); + } + return ln; +} + +describe('Receive LN payments (utility)', () => { + before(async () => { + const appId = getAppId(); + await driver.activateApp(appId); + await sleep(3000); + }); + + it(`should receive ${PAYMENT_COUNT} Lightning payments`, async () => { + let succeeded = 0; + let failed = 0; + + for (let i = 0; i < PAYMENT_COUNT; i++) { + const label = `[${i + 1}/${PAYMENT_COUNT}]`; + try { + await tap('Receive'); + await sleep(1000); + + const uri = await getUriFromQRCode(); + const invoice = extractLightningInvoice(uri); + console.info(`${label} Got invoice (${invoice.length} chars)`); + + await swipeFullScreen('down'); + await sleep(500); + + await payInvoice(invoice, PAYMENT_AMOUNT); + console.info(`${label} Payment sent, waiting for acknowledgement...`); + + try { + await acknowledgeReceivedPayment(); + console.info(`${label} ✓ Payment received`); + } catch { + console.info(`${label} ✓ Payment sent (no ack prompt)`); + } + + succeeded++; + await sleep(1000); + } catch (error) { + failed++; + console.error(`${label} ✗ Failed: ${error}`); + try { + await swipeFullScreen('down'); + } catch { /* ignore */ } + await sleep(2000); + } + } + + console.info(`\n══════════════════════════════════`); + console.info(` Done: ${succeeded}/${PAYMENT_COUNT} succeeded, ${failed} failed`); + console.info(`══════════════════════════════════\n`); + + expect(succeeded).toBeGreaterThan(0); + }); +}); diff --git a/updater/release.json b/updater/release.json new file mode 100644 index 0000000..faf8f74 --- /dev/null +++ b/updater/release.json @@ -0,0 +1,20 @@ +{ + "platforms": { + "ios": { + "version": "v0.0.0", + "buildNumber": 0, + "notes": "", + "pub_date": "2026-01-01T00:00:00Z", + "url": "https://apps.apple.com/app/bitkit-wallet/id6502440655", + "critical": false + }, + "android": { + "version": "v0.0.0", + "buildNumber": 0, + "notes": "", + "pub_date": "2026-01-01T00:00:00Z", + "url": "https://play.google.com/store/apps/details?id=to.bitkit", + "critical": false + } + } +} diff --git a/wdio.no-install.conf.ts b/wdio.no-install.conf.ts new file mode 100644 index 0000000..003bf65 --- /dev/null +++ b/wdio.no-install.conf.ts @@ -0,0 +1,48 @@ +/** + * WDIO config that attaches to the already-installed app on the simulator. + * Does NOT reinstall, reset, or modify the app. Just connects via Appium. + * + * Usage: + * BACKEND=regtest npx wdio wdio.no-install.conf.ts --spec ./test/specs/receive-ln-payments.e2e.ts + */ + +import { config as baseConfig } from './wdio.conf'; + +const isAndroid = process.env.PLATFORM === 'android'; + +const iosDeviceName = process.env.SIMULATOR_NAME || 'iPhone 17'; +const iosPlatformVersion = process.env.SIMULATOR_OS_VERSION || '26.0.1'; +const appBundleId = process.env.APP_ID_IOS ?? 'to.bitkit'; +const androidAppId = process.env.APP_ID_ANDROID ?? 'to.bitkit.dev'; + +export const config: WebdriverIO.Config = { + ...baseConfig, + specs: [['./test/specs/receive-ln-payments.e2e.ts']], + capabilities: [ + isAndroid + ? { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:appPackage': androidAppId, + 'appium:appActivity': '.ui.MainActivity', + 'appium:noReset': true, + 'appium:fullReset': false, + 'appium:autoGrantPermissions': true, + 'appium:newCommandTimeout': 300, + } + : { + platformName: 'iOS', + 'appium:automationName': 'XCUITest', + 'appium:udid': process.env.SIMULATOR_UDID || 'auto', + 'appium:deviceName': iosDeviceName, + ...(iosPlatformVersion ? { 'appium:platformVersion': iosPlatformVersion } : {}), + 'appium:bundleId': appBundleId, + 'appium:noReset': true, + 'appium:fullReset': false, + 'appium:autoAcceptAlerts': false, + 'appium:newCommandTimeout': 300, + 'appium:wdaLaunchTimeout': 300000, + 'appium:wdaConnectionTimeout': 300000, + }, + ], +};