Skip to content

feat: add spark via breez sdk - #1128

Merged
ovitrif merged 464 commits into
masterfrom
feat/breez-spark-lightning
Nov 12, 2026
Merged

ovitrif merged 464 commits into
masterfrom
feat/breez-spark-lightning

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1239
Closes #1206
Closes #1242
Closes #1244
Closes #1260
Closes #1261
Closes #1285
Closes #1296

Twin: synonymdev/bitkit-android#1697

This PR keeps Lightning in Bitkit now that Blocktank no longer sells channels. On top of the Blocktank sunset (#1127), a Breez SDK on Spark wallet carries Lightning whenever no channel can. Existing channels keep working until they close, and ldk-node keeps running the on-chain wallet.

The emergency exit is in #1217 and the Paykit route over Spark in #1191, both stacked on this PR.

Description

What this PR does

  • Transfer to Spending is back, on Spark. While Spark is ready, the suggestion card, Funding and the transfer buttons open the restored Blocktank-era intro, amount, confirm and setting-up screens. Without Spark, the Transfer Unavailable sheet stays.
  • Instant or Later. Confirm shows Network fees, Spending fee, To spending and Total, and two speed rows:
    • Instant, the default: ready in a few minutes, up to the Instant ceiling.
    • Later: after 3 confirmations, about 30 minutes, for less.
  • Setting-up follows the deposit. Instant claims early within the ceiling. Above it, or when repriced, it shows the new cost with Pay and Wait. It says when Instant isn't available, and when Spark hasn't seen the deposit.
  • Hardware wallet Transfer to Spending sends from the device to the Spark deposit address through the same screens.
  • Lightning without channels. Receives, sends and scanned invoices go through Spark when no channel can carry the payment, and land in the same received sheet, send screens and activity as channel payments.
  • Spending includes Spark. Its balance counts toward Spending, and its deposits count as in transfer from broadcast on. The most one payment can send is the larger of the channel and Spark balances.
  • Lightning Connections lists Spark first. It shows the balance, deposits with confirmations, receiving as No limit, and a detail screen with the provider, a trust note and the claim fees.
  • Transfer to Savings selection. Select funds lists Spark beside the channels, all on, and moves only what is picked. Spark's withdrawal fee shows only while Spark is selected.
  • Paykit on Spark. A Spark-only wallet publishes a Lightning endpoint and pays and receives Paykit requests and allowances through Spark.
  • Exit data backup. Spark's unilateral exit data backs up to VSS as SPARK_EXIT and is restored right after Spark connects.
  • Short screens. Confirm, Learn More and Sign with your device scroll above their bottom buttons, so headlines stay whole on 375 pt screens.

Decisions for review

  • SDK: breez-sdk-spark-swift 0.28.0, exact. Spark derives its keys from the wallet's phrase and passphrase on m/8797555', so there is no new phrase to back up. It runs independently of LDK, so a stopped node doesn't take Spending down.
  • Privacy: real-time data sync, the LNURL server and the SDK's default external input parsers are off on every network (realTimeSyncServerUrl, lnurlDomain, useDefaultExternalInputParsers). Nothing goes to Breez's data-sync or breez.tips servers, and no Lightning address is offered.
  • Chain service: mainnet uses Bitkit's Esplora at https://bitkit.to/electrs in place of mempool.space and blockstream.info.
  • API key: mainnet builds without BREEZ_API_KEY keep Spark off. The key comes from a gitignored Secrets.xcconfig (README, Build secrets), and a Release-only build phase fails without it (Docs/RELEASE.md).
  • Rail choice: a channel carries a payment when it can, and Spark otherwise. A contact's amountless Paykit invoice goes to Spark unless channel inbound covers 1,000,000 sats.
  • Claim cap: the wallet-wide cap is the recommended fee plus 2 sat/vB, so the SDK never claims early by itself. Only the user's Instant choice does, up to the ceiling: the fastest rate plus 2 sat/vB, times 99 vB, plus 0.2%. A deposit must be above that ceiling, so it is never left unclaimable.
  • Fees are never swapped silently:
    • Confirm is re-priced at the swipe, and a higher total needs another swipe.
    • A repriced claim asks first.
    • A Spark withdrawal goes out only at the fee confirm showed.
  • Send safety:
    • A send is checked against the fee Spark quoted for that invoice.
    • A refused send ends its Paykit request as failed.
    • Each send settles from the rail it went out on.
    • When Spark can't list its payments, Paykit fails closed.
    • After a restore, both rails refuse a still-valid invoice created before it that Spark hasn't listed as pending or paid, until that invoice expires. Spark's Synced can come before its payment history syncs, so it doesn't end this; the user is asked for a new invoice. The refusal can end earlier once bug: synced fires before payment history has synced breez/spark-sdk#1314 gives a payment-history sync signal.
  • Exit data: exported when Spark connects and on each exit-state change, and imported without contacting the operators.
  • Dev testing: dev builds use Lightspark's hosted regtest with no key. Not on iOS: an ESPLORA_URL path, since Spark's regtest chain lacks the /fee-estimates call ldk-node needs (QA Notes, Setup).

Out of Scope

Risks for review

  • Trust model. Spark is not self-custody in the channel sense. Funds are safe while one of three operators (Lightspark, Breez, Flashnet) deletes old keys, and 2 of 3 sign. The operator code has a per-wallet freeze switch, and Spark's terms let Lightspark suspend access.
  • Exit data size. It can reach several MB, and a large upload to Synonym's VSS is untested.
  • Fees.
    • Sending costs 0.25% plus routing; receiving is free for the user.
    • Withdrawing costs 250 × sat/vB + 750 sats.
    • The claim is a flat fee, the fastest rate times 99 vB, so it weighs on small transfers. On mainnet, a 15,000 sat transfer lost 907 sats, about 6% (CLAIM_TX_SIZE_VBYTES, deposits.rs).
  • Slower Savings to Spending. Blocktank needed 1 confirmation; Spark needs 3, about 30 minutes, unless the provider offers an early claim (Claiming on-chain deposits). That offer is the provider's call per deposit.
  • E2E on regtest. Specs that expect the Transfer Unavailable sheet take the Spark route whenever Lightspark's regtest is reachable from CI.
  • Closed-app receive. A payment received while the app is closed is claimed on the next open, with no push.
  • No BOLT12 on Spark yet.
  • App size. Breez ships as its own dynamic framework, about 9.3 MB for device arm64 uncompressed, with no duplicate symbols beside ldk-node and bitkit-core.
  • Dependency. Lightning goes through Lightspark as the Spark service provider, a dependency like Blocktank was.
  • Testing reach. Spark's regtest has no Lightning, and Spark never sees a dev build's deposit on Bitkit's regtest.

Design

N/A — no design available. Figma has no Spark or unlimited-receiving state for the Lightning Connections header, so No limit uses the spending figure's style; @aldertnl, a design for this state is still open.

UI pending design

Design for the Spark 3.2 screens was requested in the #design-ux Spark 3.2 thread, so some screens may change. The exit UI polish list is not part of this PR.

Preview

Recordings at 2x on b4f3e04 (the Later recording on 803bc6b), on an iPhone 17 Pro 2x simulator (iOS 27) in Bitkit's dark theme. Every recording is staged on a debug build with a local hook that is not in this PR: the Spark balance, the channel rows, the claim states after the swipe (Instant, Later, repriced and Pay, unavailable), the Spark withdrawal, and the tap that plays the swipe-to-confirm (simulator drags fail on iOS 27).

Transfer to Spending: intro, amount, confirm, Learn More, Later Transfer to Spending: Instant Instant repriced, then Pay
ios-spending-later2x.mp4
ios-spending-instant-2x.mp4
ios-repriced-2x.mp4
Instant unavailable Receive with no channel Lightning Connections → Spark detail
ios-unavailable-2x.mp4
ios-receive-2x.mp4
ios-connections-2x.mp4
Transfer to Savings: Spark only Transfer to Savings: channels only Transfer to Savings: one channel plus Spark
ios-savings-spark-2x.mp4
ios-savings-channels-2x.mp4
ios-savings-both-2x.mp4
Transfer to Savings: nothing selected Transfer to Savings: no Spark balance
ios-savings-none-2x.mp4
ios-savings-nospark-2x.mp4

Screens

Every screen this PR adds or changes, on an iPhone 17 Pro 2x simulator (iOS 27) in Bitkit's dark theme, rendered with stubbed state by a local hook that is not in this PR; the amount, confirm, setting-up and Transfer to Savings shots are on 803bc6b.

Transfer to Spending
Spending intro Funding Transfer intro Amount
Confirm, Instant Confirm, Later Learn More (transfer speed)
Funds in transfer
Setting up, waiting for Spark Setting up, adding Setting up, repriced Setting up, Instant unavailable
Setting up, Later Setting up, not seen yet Funds moved
Transfer to Spending from a hardware wallet
Transfer intro Amount Sign with your device Transaction signed
Transfer to Savings
Select funds with Spark Confirm with Spark fee Spark transfer failed
Lightning Connections
Spark row Spark detail
Send

Send: unified invoice Spending can't pay

QA Notes

Setup

  • Dev build (regtest) needs no key (Testing and development). Spark's regtest is Lightspark's own chain, separate from Bitkit's staging regtest, and has no Lightning network, so on dev:
    • Spark start-up, Spark invoices and Spark connections work.
    • Savings to Spending opens only on a build launched with SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL, so Spark reads Bitkit's chain; without it the transfer buttons show the Transfer Unavailable sheet.
    • Spark is funded from Lightspark's regtest faucet, sent to the deposit address on the Spark screen (Settings › Advanced › Lightning Connections › Spark); it is credited after 1 confirmation on regtest. The faucet's web page needs no account.
    • Two dev builds with Spark balances pay each other's Lightning invoices over Lightning through Spark's provider, as on mainnet.
    • Savings to Spending broadcasts on Bitkit's staging regtest, which Spark does not see, so the deposit is never claimed; Spending to Savings withdraws on Spark's chain, which Bitkit does not see.
    • Spark's chain exposes no public Electrum server, only an Esplora-compatible REST API at https://regtest-mempool.us-west-2.sparkinfra.net/api (the SDK's own regtest default, sdk_builder.rs). It lacks /fee-estimates, which ldk-node needs to start, so a dev build cannot point its node at Spark's chain as-is.

Journeys

  • J1: new transfer/spark-transfer-to-spending.xml — with Spark ready, the transfer buttons and Funding open the spending intro, then the amount and confirm screens; the confirm shows the four tiles and the Instant and Later rows, and Learn More opens the Spark copy.
  • J2: updated transfer/transfer-unavailable-sheet.xml — now needs Spark not running, since a ready Spark replaces the sheet.
  • J3: restored amount-limits/transfer-spending-over-max.xml — the amount over Max snaps to it with the toast, and delete still works.
  • J4: restored transfer/spending-confirm-amount-change.xml — a changed amount re-prices the confirm screen, and a fee that costs no more at the swipe pays at once. A higher fee at the swipe is manual test 24.
  • J5: restored hardware-wallet/transfer-to-spending.xml — the device pays the Spark deposit address and the transfer lists once in the hardware wallet's activity.
  • J6: restored hardware-wallet/transfer-to-spending-over-max.xml — the hardware amount caps on both entries.
  • J7: restored hardware-wallet/passphrase-transfer-to-spending.xml — a hidden wallet asks for its passphrase again after the session drops, and refuses a wrong one.
  • J8: new transfer/savings-select-funds.xml — each channel and Spark are rows on Select funds, all on by default; the confirm amount and "Spark fee" follow the pick, and nothing selected disables Continue. Allow about 20 s for "Spark fee". Unlike Android, a picked selection shows "Transfer all" in place of "Advanced", so reaching the list again takes "Transfer all" first.

Manual Tests

Mainnet cases are under Mainnet tests. The Emergency exit tests (25 to 33) are in #1217.

  • 1. Dev, new wallet with no channels → Receive shows Auto and Spending tabs, and the Spending invoice's payee is Spark's node, not the LDK node — Spark's regtest is not in the journey environment.
  • 2. Dev, launched with SPARK_CHAIN_SERVICE_URL set to the regtest's Esplora URL, fund Savings, Transfer to Spending → amount max is the on-chain budget, confirm shows Network fees, Spending fee, To spending and Total with the Instant and Later rows and no Advanced, swipe broadcasts to the Spark deposit address — same.
  • 10. Dev, Spark with a balance and a deposit in progress → Lightning Connections shows Spark first with its balance and the deposit's confirmations; its detail shows provider, trust note, deposits and Transfer to Savings — Spark state is not in the journey environment.
  • 11. Dev, fund Spark from the regtest faucet to the address on the Spark screen → after 1 confirmation Spending shows the amount — faucet is outside the journey environment.
    1. Retired: it expected a Spark transfer, but Bitkit records only Lightning payments, so dev builds pay each other over Lightning as on mainnet; replaced by 40.
  • 13. Dev, wallet with Spark connected → Backup settings lists Spark exit data as synced; restore the same seed on a fresh install → the log shows Imported Spark exit data before Spark is used, and Spark's balance and deposits match — needs two installs.
  • 19. Build without a Breez API key (Spark off) → Funding and the transfer buttons behave as on master: Manual Setup only and the Transfer Unavailable sheet — any build.
  • 24. Leave the confirm screen open for over a minute while fee rates move, then swipe → the tiles re-price, and a higher fee at the swipe shows "Fees changed" and needs another swipe — fee movement is not a journey capability.
  • 34. Two dev wallets, both Spark-only and each other's Paykit contacts: cold-start one and open its contact on the other → a Lightning option is offered; pay a request with the payer offline → the request fails and is not left on Payment Pending — two devices.
  • 35. A channel and a Spark balance, Transfer to Savings › Advanced → Spark is a row beside the channels, all start selected; deselect Spark → it is not withdrawn and the confirm screen drops its withdrawal fee — needs a channel next to Spark.
  • 36. Dev, Spark and a channel selected, make the withdrawal fail (for example Spark offline at the swipe) → the progress screen shows Spark transfer failed with the error as a toast and says the channel funds are moving; with Spark alone it says the balance stays in Spending — a failing Spark withdrawal is not a journey capability.
  • 38. Dev, Transfer to Savings with Spark selected → the swipe stays off while "Spark fee" loads; with Spark offline the fee error shows with Retry, and Retry prices it once Spark is back — a failing quote is not a journey capability.
  • 39. Dev, Transfer to Spending of an amount at or below the Instant ceiling → Continue shows the "Savings Balance Minimum" toast and stays on the amount screen, at both speeds; a re-quote at the swipe that falls short shows it again — any build with Savings funds.
  • 40. Dev, two devices with Spark balances, pay one's Spending invoice from the other → paid over Lightning through Spark's provider, the sender reaches Sent, the receiver gets the received sheet, and both show the activity — two devices.

Automated Checks

  • added SparkTransferFlowTests.swift — the claim decision (Instant within and over the ceiling, Standard, automatic, Declined, Later), the claim outcomes, the poll timeout, a listed-then-gone deposit, Waiting that keeps polling, Pay after a reprice, Wait after a reprice and the early-claim cap it clears, and the quote's tiles
  • updated AppCacheDataTests.swift — the transfer intro flags round-trip through the backup again
  • added SparkClaimOfferTests.swift — a quote maps to the standard claim, an automatic early claim within the cap, or a choice above the cap or with automatic claiming off, and each claim outcome maps to claimed, waiting, repriced or declined
  • added ChannelFreeRailTests.swift — rail events before the node callback are delivered, Paykit proofs and private Paykit sets read Spark payments, QuickPay reconciles them, a Spark send honours the routing fee limit, a fee over the limit or a failed estimate is a refusal before sending, Spark's sendable amount leaves room for its fee and canSend uses it, and Spark becoming ready triggers the Paykit refresh once; the duplicate-payment check and the Paykit proof fail closed when Spark can't list its payments, a retried invoice settles from its live attempt, the sendable estimate doesn't overflow, a Spark failure then a channel retry and a channel failure then a Spark retry both end in success with no failure reaching the send screen, and a settled activity keeps its status against a stale failure; while the node can't list its payments, an invoice it sent is not paid through Spark, Paykit reads the attempt as unknown and QuickPay waits; insufficient funds inside the Spark send is a refusal; the send sheet waits for a channel when Spark covers the amount but not its fee
  • updated PaykitPaymentProofServiceTests.swift — a Spark refusal before sending, bare or wrapped, ends the payment as failed and removes its proof
  • added SparkLightningSendTests.swift — a Lightning invoice with no channels opens the payment when Spark can pay, is rejected without Spark, and a Spark send returns before completion
  • added SparkTransferActivityTests.swift — Spark deposits and withdrawals are marked as transfers, failed ones are not, and withdrawals are listed with the fee Spark kept
  • added SparkExitBackupTests.swift — SPARK_EXIT uploads the exported state under its own key, a restored state is staged until Spark connects, and one from another network is skipped; exit data not yet imported blocks the upload, an unreadable remote backup backs off, and the latest backup time skips the exit data
  • added SavingsSelectionTests.swift — every channel and Spark move by default, Spark alone or channels alone, a deselected Spark is not withdrawn and Spark alone closes no channel, a partial channel pick, a channel no longer ready is dropped, nothing selected is empty, committing resets the pick to all, and so does entering the flow
  • added SparkConfigTests.swift — Spark connects with no Breez data-sync server and no LNURL domain on every network
  • added SparkWithdrawalTests.swift — a withdrawal quote holds for the same address and balance until 30 s before it expires and an estimate never does, the fee is priced once per balance to one address and again for a new balance, a slow quote gives up and prices again next time, the fee timeout returns at its deadline while the SDK call ignores cancellation, Close instead waits for the fee and for the swipe's re-price, a failed Spark withdrawal shows as failed and names whether channels moved, and a withdrawal's receipt is matched by its txid
  • updated SavingsSwapTests.swift — the commit takes whether Spark is withdrawn
  • added SparkPaymentSafetyTests.swift — a settled Spark payment is recorded and its node event raised even when the refresh after it fails; a sent withdrawal succeeds when the refresh after it fails and fails on Spark's failed status; a withdrawal fee above the shown one is refused, the swipe waits for the fee, a higher fee at the swipe is shown and asks again, and the withdrawal sends with the shown fee as its cap; a deposit at or below the claim ceiling is refused; SDK errors show app copy; a send must fit its quoted fee

Channel-only audit

Every place that assumed channels, checked for a Spark wallet with no channels and compared with Android:

  • Fixed:
    • AppViewModel scan check: rejected every Lightning invoice without channels before considering Spark; now pays through Spark when its balance covers the amount, and the toast counts Spark's balance.
    • SendSheet insufficient toasts: count Spark's balance.
    • SendSheet channel wait: no longer waits for a reconnecting channel when Spark can pay the amount.
    • SparkManager send: returns as soon as the payment starts, so its completion event reaches the send screen and Paykit settlement instead of leaving "Payment Pending". Android does the same.
    • LightningService.emitChannelFreeRailEvent: Spark events that arrive before the node's event callback is set are kept and delivered once it is.
    • PrivatePaykitService+Invoices settled and attempted payment sets: include Spark payments, so a private invoice Spark received isn't reused and a contact invoice Spark paid isn't paid again.
    • PaykitLightningPaymentProofLookup: settles a Paykit proof from Spark's record when its completion event was missed, also before the node lists its payments.
    • QuickPayPaymentCoordinator reconcile: includes Spark payments.
    • SparkManager activity: recorded through the same path as LDK payments, so a Paykit contact and seen state survive Spark syncs.
    • TransferViewModel swap to Savings: also withdraws Spark's balance when Spark is selected, as the confirm screen counts it.
    • PaykitAllowanceManager.lightningReady: Spark ready counts as ready.
    • LightningService.send on Spark: enforces the caller's routing fee limit (Paykit auto-pay's 1%) against Spark's fee estimate.
    • LnurlPayAmount: caps and shows the larger rail balance, not the sum of both.
    • AppStatusHelper.channelsStatus: a ready Spark wallet shows the Lightning connection as ready.
    • LNURL withdraw "no capacity", Receive amount liquidity and CJIT, Lightning Connections receiving capacity and counter.
  • Channel-only by design:
    • Channel list and detail, close channel, Blocktank orders, node info, LNURL channel, manual channel funding and the multi-channel Savings path: they manage channels.
    • GiftLoading: gift claims open a Blocktank channel, which Blocktank no longer sells.
    • The notification extension: it wakes the LDK node only; Spark receives while closed are claimed on the next open (no push).
    • nodeLifecycleState gates on send and receive: the node runs beside Spark, as on Android.
    • WalletViewModel balance derivation while the node isn't up: keeps the last balances, which include Spark.
  • Already Spark-aware: maxSendLightningSats, receive invoice creation and tabs, Paykit Lightning endpoint publishing, the Spending card transfer button, the LNURL withdraw invoice.
  • Not covered: a regtest Spark-to-Spark transfer (preferSpark) has no Lightning details, so it maps to no event; mainnet sends are Lightning payments.
  • Android has the same gaps in the proof lookup, the Spark activity contact, the swap to Savings, the channel wait and App Status; its flows don't hit them in the cases run on device.

QA

Each result at the revision it ran on (✅ passed, ❌ failed, ⏭️ not run). Tested on an iPhone 17 Pro simulator (iOS 27.0) against Spark's hosted regtest.

  • ✅ J1 on Breez SDK 0.28.0 at 43abf3e, on a simulator against Spark's hosted regtest; also at 0eed9c2a on 0.27.2.
  • ⏭️ J2: a Debug build always connects Spark when its regtest is reachable, so "Spark not running" can't be set up on a simulator; on 5eab42d without SPARK_CHAIN_SERVICE_URL, both transfer entries open the sheet and its Learn More and Continue work, while Funding and Receive show the Spark-ready screens.
  • ✅ J3 at 5eab42d.
  • ⏭️ J4: its fixture needs 800,000 sats of Savings UTXOs, more than this run's test funds.
  • ⏭️ J5, J6 and J7: they need the Trezor emulator, Bridge and the local regtest stack from bitkit-docker, not part of the hosted regtest setup.
  • ✅ J8's cases at 674f0da; the journey file itself was not rerun since.
  • ✅ 1 at 4b58c22, on the earlier flow.
  • ✅ 2 at f63e613, with J1.
  • ✅ 10 and 11 at f63e613.
  • ✅ 12 at 5eab42d (retired since: that run did not check whether it paid as a Spark transfer).
  • ⏭️ 13: runs on mainnet only, under Mainnet tests.
  • ✅ 34 at 5eab42d, except the payer-offline leg: a simulator shares the host's network, so one wallet can't go offline alone.
  • ⏭️ 19, 24.
  • ✅ 35 at 674f0da.
  • ⏭️ 36, 38, 39.

Device tests skipped ⚠️:

  • Paykit on Spark: a request paid while the payer is offline (34).
  • Mainnet: see Mainnet tests.
🟢 Transfer to Spending with Later, Transfer to Spending with Instant, Claim states after the swipe
Transfer to Spending with Later

J1 and 2 at f63e613: Savings › Transfer To Spending opened the Spending intro, the amount screen's 25%, Max and unit toggle worked, the confirm screen showed the four tiles with Instant and Later, Learn More showed the Spark copy, and the swipe ended on setting-up with Speed: Later and the 3-confirmation text.


Transfer to Spending with Instant

The Spend suggestion card opened the Transfer intro, then Funding with Transfer from Savings, Use Other Wallet and Manual Setup; with Instant, setting-up waited for Spark and after 5 minutes said Spark hasn't seen the transfer, since the deposit went to Bitkit's staging chain.


Claim states after the swipe

With the claim offer staged by a local hook, Instant showed Spark adding it and then the success screen, a repriced offer showed Instant now costs ₿ 812 with Wait and Pay and Pay ended on success, and a declined one showed Instant isn't available.


🟢 Pay a Spending invoice between two Spark wallets, Paykit contacts on Spark, Transfer to Spending over Max
Pay a Spending invoice between two Spark wallets

12 at 5eab42d: wallet B's Spending invoice, paid from wallet A for 1,000 sats, sent from A's Spark balance (19,901 to 18,898) and showed Bitcoin Sent; B showed Received Instant Bitcoin and both activity lists have the payment.

c12-2x.mp4

Paykit contacts on Spark

34 at 5eab42d: two Spark-only wallets with Bitkit profiles, saved as each other's contacts. After a cold start of B, paying B from A's contact screen offered Spending with a Lightning invoice (Instant, 1 day expiry) and 500 sats went through Spark. A 300 sat payment request from B reached A, and A paid it: Bitcoin Sent on A, not Payment Pending, and both contacts' activity lists the two payments.

c34-2x.mp4

Transfer to Spending over Max

J3 at 5eab42d, launched with SPARK_CHAIN_SERVICE_URL on the staging Esplora: Savings › Transfer To Spending opened the intro, then the amount screen with Available 39,431. Nine taps on 9 snapped the amount to 39,431 with the Spending Balance Maximum toast, and delete took it to 3,943. The toast isn't in the accessibility tree, so the recording shows it.

j32x.mp4

🟠 Transfer Unavailable sheet with Spark running
Transfer Unavailable sheet with Spark running

J2's first steps at 5eab42d, on a build launched without SPARK_CHAIN_SERVICE_URL: Savings › Transfer To Spending and Spending › Transfer From Savings opened the Transfer Unavailable sheet with Manual Setup Required, Learn More opened blog.bitkit.to and Continue opened Manual Setup. Spark was connected, so Funding listed Transfer From Savings and Use Other Wallet above Manual Setup and the journey stopped there.

j22x.mp4

🟢 Fund Spark from the faucet
Fund Spark from the faucet

10 and 11 at f63e613: 100,000 sats from Lightspark's regtest faucet to the address on the Spark screen arrived as 99,901 after a 99 sat claim fee, and Lightning Connections and the Spark detail listed them.


🟢 Default and nothing selected, Partial, Channel and Spark, Spark only, Channels only
Default and nothing selected

With two channels and 99,901 sats on Spark, the confirm screen showed 535,993 sats with a 2,190 sat Spark fee and Advanced. The list showed Connection 1, Connection 2 and Spark, all on; with all three off, the total read 0 and Continue was disabled.


Partial

With Connection 2 and Spark off, the confirm showed only Connection 1's 186,780 sats, no Spark fee, and Transfer all. After the swipe, Spending went from 535,425 to 348,929: Connection 1 closed, the other channel and Spark's 99,901 stayed.


Channel and Spark

The remaining channel and Spark, all selected, showed 349,213 sats with the Spark fee; the swipe closed the channel and withdrew Spark, and Spending read 0.


Spark only

With Spark refunded from the faucet and no channels, the confirm showed 99,901 sats with the Spark fee and no Advanced button; the swipe withdrew it and Spending read 0.


Channels only

With two new channels and no Spark balance, the confirm showed Advanced and no Spark fee, the list had only the two connections, and the swipe closed both.


Evidence

Unit and UI suites are CI's selection; "not driven" means not run on a device.

  • 9f251e6: the unit suite passes on an iPhone Air simulator (iOS 27.0), with tests for the refused savings swipe, Spark held until the node first lists its payments and the retried early-claim cap clear; SwiftFormat shows no findings on the changed files. The UI suite was not run. Not driven.
  • 597a8c1 (with 3853c12): the unit and UI suites pass on an iPhone 17 simulator (iOS 27.0), with tests for the hardware minimum, the cross-rail proof and QuickPay outcome, the sent-quote fee limit, the submission check, per-deposit claims and a listed deposit after the polling window; CI (unit and UI suites, integration tests, E2E, SwiftFormat) passes, after one rerun of two timing-sensitive UI tests. Not driven.
  • a538a47: the unit and UI suites pass on an iPhone 17 simulator (iOS 27.0), with tests for the Paykit endpoint and plain receive rules and the allowance payable rule; CI (unit and UI suites, integration tests, E2E, SwiftFormat) passes. Not driven.
  • 9c17fd8, 14f29f9 and cde15c4: the unit and UI suites pass on an iPhone 17 simulator (iOS 27.0), SwiftFormat shows no new findings and the translation check passes; SwiftFormat and validate pass in CI. Not driven.
  • 0eed9c2a: the unit and UI suites pass on an iPhone 17 Pro simulator (iOS 27.0), SwiftFormat shows no new findings and the translation check passes; J1 passes on an iPhone 17 Pro simulator (iOS 26.5) against Spark's hosted regtest with Savings funded on Bitkit's staging regtest; J8 was not rerun.
  • f63e613, the results above (transfer, Spark detail): Debug simulator builds on an iPhone 17 Pro simulator (iOS 27.0), Savings funded on Bitkit's staging regtest, Spark funded from Lightspark's regtest faucet.
  • aa01843: SparkConfigTests checks that Spark connects with no data-sync server and no LNURL domain on every network.
  • 58fa5fe1, Paykit on Spark: the Debug device build passes, and ChannelFreeRailTests, PaykitPaymentProofServiceTests, PaykitAllowanceExecutorTests, SparkLightningSendTests and SparkTransferFlowTests pass on an iPhone 17 simulator. Not driven.
  • d20fc7c8, restored flow: the Debug device build passes, and SparkTransferFlowTests, AppCacheDataTests and HwWalletVendorPresentationTests pass on an iPhone 17 simulator; SwiftFormat and the translation check pass locally. Previews cover every confirm state (Instant, Later, loading) and every setting-up state (looking, adding, done, repriced, Instant unavailable, Later, not seen). Not driven.
  • 4ac08000: the simulator build and the Spark unit tests pass locally; SwiftFormat and validate pass in CI (run).
  • 92a03765: the mainnet Release build for device, SwiftFormat and the unit suite pass (run).
  • f01de3b: the unit suite passes on an iPhone 17 Pro simulator, with SwiftFormat and the translation check.
  • 674f0da, Transfer to Savings selection (35, J8's cases): on an iPhone 17 Pro simulator (iOS 27.0) with Spark on its hosted regtest and channels on Blocktank's staging regtest.

Device runs on the earlier flow, at the revisions they ran on:

  • 1 on a Debug simulator build of 4b58c22 and f79c9f5, Spark's regtest: new wallet without channels; the log shows Connected Spark wallet on 'regtest'. Receive shows Auto and Spending with a Lightning QR whose invoice names an explicit payee and a route hint to 0202f87a…bb76, which an LDK node without channels does not add. aed1f9f builds for the simulator (arm64).
  • 10 on 34dfdef (now bb09eeb): Lightning Connections listed Spark first with its balance and the pending deposit with live confirmations; the detail showed provider, trust note, pending and claimed deposits with the claim fee, and Transfer To Savings.
  • 10 and 2 on 5c3b6f7 (now cbc59e9): the Spark row and detail drew a full purple bar with no receiving side, the header read Receiving capacity No limit, and a 20,000 sat Spark transfer confirm showed network fee 458, Claim fee 99, To spending 19,901 and Total 20,458, with no background toggle, Learn More or Advanced.

Still open

  • Translations. The new transfer and note keys are English in every locale file; they need translating. Tracked in chore: translate spark strings #1270.
  • Restored flow: J4 to J7 and 24 not run yet; the claim states other than Later and "hasn't seen" are reached on dev only through a local hook, since Spark never sees a staging-chain deposit.
  • The mainnet cases are under Mainnet tests.
  • Paykit on Spark: a request paid while the payer is offline is not driven on a device.
  • A payment received while the app is in the background is recorded as activity, but the received sheet does not show.
  • Transfer to Savings Spark withdrawal (36, 37): unit tests only. The fee quote reuse, the failed withdrawal screen and the withdrawal receipt were not driven: on dev a failure needs staging and the withdrawal lands on Spark's chain, which Bitkit does not see.
  • Exit data backup, the channel-free Lightning send and the audit fixes on a9aa36fd: the unit suite passes except SendConfirmationLifecycleTests.testCustomFeeWalletSwitchSurvivesFeeScreenNavigation, which fails the same way on the base branch at 77b01a4a. Not yet driven on a device.
  • Pending at broadcast on 8f6de109: built for the simulator only, not driven; the twin Android change was driven on an emulator.

Mainnet tests (run by the author)

These run on real devices with mainnet funds, by the author. Reviewers and review bots don't need to run them, and they don't block review.
Setup: a mainnet build with BREEZ_API_KEY in Secrets.xcconfig (copy Secrets.xcconfig.template). Round 1 ran on an iPhone.

Round 1

  • Pay a Lightning invoice through Spark with no channel, on a9aa36f (result).
  • Restore Spark exit data after a wipe: 16 leaves were imported before Spark was used, and the balance and claimed deposit came back, on a9aa36f (result).
  • Transfer to Savings of the Spark balance, on a9aa36f (result).
  • Claim a deposit after 3 confirmations, on a head before the 27 Oct rebase (result).
  • Transfer to Spending: Spending showed it as an incoming transfer, on f25edfe, a build of feat: add usdt on spark via breez sdk #1145 on an earlier head of this PR (result).
  • Transfer to Savings arrival: listed as a plain received payment; fixed in 93aec43, not rerun (result).
  • Paykit request paid over Spark: the payment arrived at once, but the payer stayed on Payment Pending; fixed in 735e972 and synonymdev/bitkit-android@024b6bb85, not rerun (result).

Round 2 (pending)

On the final heads, with a mainnet build and small amounts.

  • Transfer to Spending with Instant: the setting-up screen says Spark is adding it, then Funds moved to spending before 3 confirmations; when the provider asks more than the ceiling, it shows Instant now costs X with Pay and Wait.
  • Instant repriced: Pay claims at X; Wait lands after 3 confirmations.
  • Instant, leave the setting-up screen at once: the claim still happens within the ceiling.
  • Transfer to Spending with Later: no early claim; the deposit lands after 3 confirmations at about the shown fee.
  • Hardware wallet Transfer to Spending: sign on the device, the setting-up screen follows the deposit, and the transfer lists once under the hardware wallet.
  • Spark ready, no channels: Funding shows Transfer from Savings and Use Other Wallet above Manual Setup, and both transfer entries open the same flow.
  • No channels, receive over Lightning: the received sheet and activity show it.
  • No channels, pay a Lightning invoice: paid through Spark; after Spark's next sync the activity shows Spark's fee, not 0.
  • Receive while the app is closed, then open it: the payment is claimed and shown.
  • Pay an invoice and drop the connection right after the swipe: Payment Pending shows, then it settles as sent or failed; paying the same invoice again sends nothing twice.
  • Transfer to Savings with only a Spark balance: the arrival shows as a transfer From Spending with no received sheet, and the Spark screen lists the withdrawal with its fee.
  • Transfer to Savings with a channel next to Spark: deselecting Spark keeps it in Spending and drops its withdrawal fee.
  • Existing channel: payments within its capacity still go over the channel.
  • Restore on another device: Spark exit data is imported before Spark is used, and the Spark balance and deposits come back.
  • Two Bitkit contacts, one Spark-only: a Paykit request paid over Spark ends on Sent for the payer and paid for the payee; a second Lightning-only request offers Spending and pays.
  • Spark's fee estimate and chain reads go to https://bitkit.to/electrs.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: medium), claude-sonnet-5-5 (reasoning: high)
  • Implementation: claude-opus-5-5 (reasoning: medium)
  • Review: claude-sonnet-5-5 (reasoning: high)

Taken from the run logs of every session and subagent that planned, wrote or reviewed this PR, which record the model and reasoning effort per turn. The claude-sonnet-5-5 planning runs were research and audit subagents.

@ovitrif ovitrif added this to the 3.1.0 milestone Oct 26, 2026
@ovitrif ovitrif self-assigned this Oct 26, 2026
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from aed1f9f to 82d6014 Compare October 26, 2026 14:04
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 2 times, most recently from bb09eeb to bf0e9cd Compare October 26, 2026 17:18
@ovitrif
ovitrif force-pushed the fix/blocktank-sunset-sheet branch from 9d5e276 to 1883357 Compare October 26, 2026 17:25
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 3 times, most recently from 856799e to 7897f16 Compare October 26, 2026 22:47
@ovitrif ovitrif changed the title feat: keep lightning through breez sdk on spark feat: add spark via breez sdk Oct 26, 2026
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from 4534525 to a9aa36f Compare October 27, 2026 02:18
@ovitrif ovitrif mentioned this pull request Oct 27, 2026
5 of 7 tasks
@ovitrif

ovitrif commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

QA:
Tested on an iPhone, mainnet, by @ovitrif, 26–27 Oct; amounts are sats. Balances, names, addresses and txids are masked.

🟢 Pay a Lightning invoice from iOS
The iPhone paid a plain Lightning invoice from the Pixel through Spark with no channel, and the Pixel received it at once. Ran on a9aa36f.

🟢 Restore Spark exit data
Exit data backs up as its own category, and after a wipe the iPhone imported 16 leaves of Spark exit data before using Spark; its balance and claimed deposit came back. Ran on a9aa36f.

🟢 Move Spending to Savings
Transfer to Savings withdrew the Spark balance for a 1,470 sat fee and 4,917 sats arrived in Savings; it was listed as a plain received payment, fixed in 93aec43 to show as a transfer. Ran on a9aa36f.

🟢 Claim a deposit
Spark claimed the 7,500 sat deposit after 3 confirmations, keeping 198 sats, and Spending showed 7,302. Ran on an earlier head of this PR, before the 27 Oct rebase.
🟢 Transfer to Spending
Transfer to Spending sent 9,632 sats to Spark and Spending showed it as an incoming transfer. The confirm screen estimated a 99 sat claim fee where claims so far took 198 to 297; 7388559 now shows the most Spark may keep. Ran on f25edfe, a build of #1145 on an earlier head of this PR.

🔴 Pay a Paykit request over Spark
The iPhone paid the Pixel's 204 sat Paykit request over Spark and it arrived at once, but the iPhone stayed on Payment Pending: the payment settled inside the send call, before the send screen started watching for it. Fixed in 735e972 and synonymdev/bitkit-android@024b6bb85, not rerun on a device yet. Ran on 56d7adf, a build of #1145 on 9ddd96b. An earlier attempt kept loading for minutes while Paykit's shared state stayed locked; since a5d94ed the sheet stops at once and says the contact's payment details aren't ready, and the lock itself is a Paykit issue reported separately.

@ovitrif
ovitrif force-pushed the fix/blocktank-sunset-sheet branch from 6104b95 to 23b8b66 Compare October 27, 2026 17:39
@ovitrif ovitrif removed this from the 3.1.0 milestone Oct 28, 2026
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Removed from the 3.1.0 milestone: this draft is the deliverable of a finished Spark experiment and does not ship in 3.1.0.

@ovitrif
ovitrif changed the base branch from fix/blocktank-sunset-sheet to master October 28, 2026 13:25
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Retargeted this draft to master before merging its base, #1127, so deleting that branch would not close it.

@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from 735e972 to a77cb76 Compare October 28, 2026 14:42
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Rebuilt this branch on master after the Blocktank sunset (#1127). Master removed the old Transfer to Spending flow, so Spark now has its own amount, confirm and deposit screens, reached from Funding and the transfer buttons while Spark is ready. The deposit screen offers Breez's early claim at the fee the provider quotes for that deposit.

The history was rewritten into new commits, so the shas under Evidence predate the rebuild. This PR stays a draft.

@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 2 times, most recently from 3808d9d to 92a0376 Compare October 29, 2026 12:03
@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Cancelled the unit-tests run on 782d2ff (run): that head was replaced by 92a0376, which folds a SwiftFormat fix into the same change, and a new run is going on it (run).

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the 36 own commits since 7ce626fe0 (head e467a2ff1): one HIGH inline (pwltr's candidate, now reproduced), so requesting changes on that alone.

Checked and clean:

  • Fee the swipe approved (23e71bdf5): recheckSparkWithdrawFee returns the fee shown when the swipe began; a re-pricing during the recheck refuses the commit; withdrawAll checks the prepared fee against that cap before sendPayment (SparkManager.swift:813-817).
  • Pre-restore invoices (199729d81, 18946a0f0):
    • Both rails go through settledEarlierAttempt.
    • With no Spark row, or Spark unreadable, a pre-restore unexpired invoice is refused on either rail.
    • A node-paid row returns paid without re-sending.
    • sparkRestoredAt is cleared on a failed restore and on wipe.
  • Re-route on source change (d1efd4f39): a stale answer is dropped by isCurrent (generation, source, endpoints). A leg is paid from the swipe-time serviceLeg with maxFeeSats = debit − delivered.
  • Contact withdrawal during an exit (9fab00f9f): refused up front, and the send runs inside spends.run, so an exit starting during prepare refuses it and the journal, proof and allowance are released.
  • Generation guards (5176fa0c1, 437a97913, f0d06d9bc, 1af56fc9d, e1dbb0b9b): validGeneration is nil whenever isRedoRebuild, and the guard runs before save and broadcastReady.
  • Payout confirmation: markOnchainActivityAsConfirmed re-reads on the core queue, so the stale-upsert issue I raised on Android doesn't exist here. The Electrum fallback is fixed in #1304.
  • Exit funding after restore: the forward scan is fixed in #1305. A quote taken before recovery runs still reuses the restored index; I put a failing test for that on Greptile's #1305 thread.

The 0.28.2 bump in #1306 changes how the exit driver sees a lost CPFP race (.redo instead of deadCpfpChildren); a regtest exit run on 0.28.2 is worth doing before it lands.

Device gate: not re-run for this delta; the finding is unit-reproduced and the fix should land first. Journeys to re-run after it: transfer/spark-emergency-exit.xml, settings/app-status-emergency-exit.xml (updated in c07e4ce38), paykit-spark-route.xml. perf: recoverFunding adds 20+ sequential Esplora reads after a restore, and each refresh starts an Electrum address read per unconfirmed contact payout.

Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 71f103fca. The HIGH is fixed as suggested: payDirectly re-checks isCurrent after both the LNURL and BOLT11 decodes before writing anything, the caller maps that to .stale, and the new PaykitRouteTests case pins it. The other commit since my review is the #1304 merge, which I approved there.

Device gate: not re-run for this delta (journeys to re-run once the stack is up: transfer/spark-emergency-exit.xml, settings/app-status-emergency-exit.xml, paykit-spark-route.xml); the delta's fixes are covered by unit tests. CI: Run Tests and build-local still pending at this head.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Device gate result: approving, nothing above LOW.

Device gate: 71f103fca — journeys: 3 passed (settings/app-status-spark-row.xml, transfer/spark-emergency-exit.xml 12/12, settings/app-status-emergency-exit.xml); perf: not relevant; l10n: no UI text changed.

  • Setup: iPhone 17 simulator on the bitkit-docker spark profile, with SPARK_LOCAL_CONFIG_B64, local Electrum and SPARK_CHAIN_SERVICE_URL pointed at the local chain. Fresh wallet with no channel: Spending 199 865, Savings 500 000.
  • The exit quote was 192 512 sats over 6 leaves (fees 12 122, fee funding 22 025, 7 353 stays on Spark), paid from savings (fee 422). The funding tx confirmed and the exit reads "In progress".
  • Lightning Connection reads "Using Spark" outside the exit and "No open connections" during it, under the Emergency Exit row.
  • ⚠️ Not run: paykit-spark-route.xml, which needs the Paykit fixture stack.

Observation, not a finding: the Spark and Emergency Exit rows in App Status had no tap target in the accessibility snapshot, so they were opened by touching the row text.

…forward

fix: find exit funding past a restored counter

@pwltr pwltr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed fe3505b.

Medium candidate — Close returned withdrawals that fail without payout details. In Bitkit/Managers/SparkManager.swift:1152, failed-payment cleanup depends on withdrawal details, while the unlisted-payment expiry path requires no recorded payment ID. A returned withdrawal with an ID that later fails without details can therefore retain its journal indefinitely, blocking subsequent Spark contact payments and leaving pending UI or request proofs unresolved. Handle a matched terminal failed status independently of payout details while preserving unknown or pending journals. The application trace supports this candidate; the combined SDK scenario remains unconfirmed.

Review coverage is partial: complete diffs were read for 53 of 187 changed paths. This is not a completed whole-change review.

Review details

General review
Model: GPT-6.1 Sol
Reasoning effort: high

@pwltr pwltr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed bbf02ca, which merges #1306. It bumps the Breez Spark SDK to 0.28.2 (the project pin and Package.resolved agree), and the exit driver now waits one check before rebuilding over a step that just read as unverified. It also ends a funded unverified ask without rebuilding once its steps read again. I found nothing new.

Review details

General review
Model: Claude Opus 5.5
Reasoning effort: high

@ovitrif

ovitrif commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

QA for bbf02ca:

Tested on an iPhone 17 Pro 2x simulator (iOS 27.0) against Spark's hosted regtest, with Breez SDK 0.28.2 (#1306) and fresh dev wallets.

  • ✅ transfer/spark-emergency-exit.xml at bbf02ca. The explainer, quote (12,288 to exit, 6,790 network fees, about 5,498 received, 11,481 fee funding, 5,613 stays on Spark) and Fund the fees show as written; the quote loaded at once, so "Getting a quote…" was not caught. Pay from savings is present but off, since Savings sits on Bitkit's staging chain, which Spark doesn't read. Fund from another wallet shows the QR and bcrt1 address with Continue off, and backing out leaves Transfer To Savings enabled.
    j1-exit2x.mp4
  • ✅ settings/app-status-emergency-exit.xml at bbf02ca, on an exit funded from another wallet, not from Savings, for the same chain reason. Emergency Exit reads In progress below Lightning Connection, which reads No open connections. The row opens Progress, OK returns to the wallet home, and the row is still there after.
    j2-appstatus2x.mp4
  • ✅ paykit-spark-route.xml at bbf02ca, rerun with the paykit-rs 0.1.1-rc8 fixtures from fix: paykit fixtures on paykit-rs 0.1.1-rc8 bitkit-docker#38: the saved contact linked privately and a 20,000 send went out over the Spark route and confirmed on chain. The first run stopped on recovery_required, because bitkit-docker had no rc8 fixture yet. Details and recording: feat: add spark via breez sdk #1128 (comment)

Full in-app emergency exit, running: the exit started from the 12,288 above in 2 leaves, with 11,481 fee funding in 18 parts from another wallet. Progress shows 17 of its 22 exit transactions published, with no rebuild so far. The last refund is claimable at height 3,709,145, about 30 hours after the start; the result goes here at Done.

exit-start2x.mp4

@ovitrif

ovitrif commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Merging: approved by @jvsena42 and @pwltr on the current head, CI green, no open threads.

The two PRs still open into this branch move to master next: #1309 (Redo exit hold) and #1312 (Breez SDK 0.28.3). UI polish from the updated Spark designs follows as separate PRs into master.

@ovitrif

ovitrif commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Added closing references for the seven 3.2.0 issues fixed by PRs into the Spark branch, which GitHub didn't link because they merged into a non-default branch: #1206, #1242, #1244, #1260, #1261, #1285, #1296.

@ovitrif

ovitrif commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

paykit-spark-route.xml rerun at bbf02ca: ✅

Rerun on bitkit-docker's local stack with the paykit-rs 0.1.1-rc8 fixtures from synonymdev/bitkit-docker#38, on an iPhone 17 Pro 2x simulator against Spark's hosted regtest.

  • The wallet claimed a 30,000 deposit, and Spending showed 29,901.
  • Saving the fixture-issuer as a contact completed the private link: no recovery_required.
  • Send › Contact › fixture-issuer opened the amount screen. I sent 20,000 from Spending: Confirm showed route Spark, a ₿ 2,190 fee and the over-50% warning.
  • The app showed Payment Pending while the Spark withdrawal was still pending, then Bitcoin Sent. The withdrawal paid the fixture's address 20,000 on chain, and Spending ended at 7,711.

The same journey passes on Android at 2f18eb69b, with the same route, fee and result.

ios-journey2x.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

6 participants