Skip to content

feat: lightning-first primary node - #1160

Closed
ovitrif wants to merge 10 commits into
masterfrom
feat/lightning-first-primary
Closed

ovitrif wants to merge 10 commits into
masterfrom
feat/lightning-first-primary

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Twin: synonymdev/bitkit-android#1728

This PR tries Corey's Lightning-first approach on mainnet, next to the Spark drafts (synonymdev/bitkit-android#1697, #1128). Bitkit runs ldk-node built from coreyphillips/ldk-node feat/lfbw-wallet-client and uses a primary node as its liquidity provider: the primary opens a channel just in time, or grows the existing one by splicing, from its own on-chain funds. Anyone can run a primary; this build points at a Beignet primary run for this test. Where the sunset left Transfer to Spending on manual setup, it now opens a channel with the primary.

Description

  • Builds against ldk-node from Corey's branch: the Swift package points at ovitrif/ldk-node lfbw-bitkit (Corey's branch plus regenerated Swift bindings, which his branch had not refreshed), whose LDKNodeFFI.xcframework is the v0.7.0-rc.77-lfbw.1 release asset.
  • Maps the four new liquidity errors (LiquiditySourceInsufficientFunds, LiquidityRequestRejected, LiquidityRequestTimedOut, LiquidityReceiveAmountTooSmall) in AppError.
  • Adds Env.primaryNode (mainnet only): 024a6cc6…299c@bore.pub:39101. The primary runs Beignet 0.35.1 with its liquidity-provider role on and zero fees; ldk-node's client dials only TCP, not Tor, so the phones reach it through a public TCP relay. The transport stays BOLT 8 end to end.
  • Selects the primary with Builder.setPrimaryNode, which also trusts its zero-conf channels, and connects to it when the node starts. Idle-peer cleanup leaves it connected, so an empty wallet can receive.
  • Creates a fixed-amount receive invoice through the primary (receiveViaPrimary) when no ready channel has the inbound for it: the primary opens a channel on the first payment, or splices into the existing one when the payment is larger than its inbound. The fee ceiling is the primary's quote. Without an amount, Receive on Spending opens the amount editor, and Receive from the Spending screen opens Receive, instead of the Transfer Unavailable screen.
  • Opens Transfer to Spending (Savings, Spending, the transfer suggestion) on manual setup prefilled with the primary; confirming opens a channel from on-chain funds, or splices into the ready channel when one exists. Without a primary (dev and testnet builds) the Transfer Unavailable sheet stays.
  • Reads a PRIMARY_NODE_URI config value (Info.plist build setting, or the launch environment in debug) that overrides the primary, so regtest builds can point at a local primary.
  • Transfer to Savings, sending and the rest of Lightning use the existing native ldk-node paths.

Out of Scope

  • Services/LightningService.swift: the primary is hardcoded; choosing or scanning another primary is not built.
  • Receive: amountless Lightning invoices through the primary (the client supports fixed amounts only), and showing the primary's fee before the invoice.
  • Transfer to Savings by splice-out; it still closes the channel.
  • Offline receive, direct funding and swaps from Corey's branch.
  • Copy and design for the primary flows; the manual setup screens are reused as they are.

Risks for review

  • Force close with the primary. setPrimaryNode adds the primary to trustedPeersNoReserve, the setting that made ldk-node skip broadcasting force closes with Blocktank peers. A force close with the primary may stall until the primary broadcasts.
  • One primary behind one relay. Every wallet depends on the hardcoded primary and the bore.pub relay; when either is down, receiving beyond existing inbound fails.
  • Ten-minute invoices. Primary invoices expire after 10 minutes, and a just-in-time channel becoming ready replaces the invoice shown on Receive.
  • Back-to-back receives above the channel. A just-in-time splice needs confirmed primary inputs, so a second large receive may be refused until the previous splice confirms.
  • Commitment fee mismatch. The released Beignet primary opens just-in-time channels at a fixed 253 sat/kw commitment fee (Fresh channel and direct-funding opens retain a fixed 253 sat/kw commitment fee despite funding estimates coreyphillips/beignet#1873, open); ldk-node refuses the open whenever its fee floor is higher, so just-in-time receives fail once fees rise above about 1 sat/vB. Seen on regtest at 3 sat/vB.
  • Primary offline. A receive that needs the primary shows a spinning QR with no error while the primary is unreachable.
  • Primary liquidity. Sends to outside nodes and receives from them route through the primary's own channels.

Design

N/A — no design available.

Preview

N/A

QA Notes

Setup

  • Build: xcodebuild -scheme Bitkit -configuration Release -destination generic/platform=iOS (Release runs on mainnet), on a fresh wallet. The build replaces any installed to.bitkit development build and keeps its wallet.
  • Primary: Beignet 0.35.1 on mainnet with Electrum bitkit.to:9999, liquidity provider on, reached at 024a6cc6…299c@bore.pub:39101.

Journeys

N/A — not drivable; see Manual Tests.

Manual Tests

  • 1. Empty wallet, Receive, Spending, enter 20,000 sats, pay from another phone or wallet → payment arrives; Lightning Connections shows a channel with the primary — needs mainnet funds and a second wallet
  • 2. Send a Lightning payment to an outside invoice → payment succeeds — mainnet
  • 3. Receive more than the channel's inbound → payment arrives; the same channel grows by splice — mainnet
  • 4. Savings, Transfer to Spending, enter an amount, confirm → channel opens with the primary, or the ready channel grows — mainnet on-chain funds
  • 5. Spending, Transfer to Savings → channel closes cooperatively and funds return on-chain — mainnet
  • 6. Pay the other phone's Lightning invoice → payment arrives through the primary — needs two devices on one primary
  • 7. Reset the wallet and restore from the phrase → Spending balance, activity and the channel with the primary come back — mainnet
  • 8. Stop the primary, then receive above inbound and send → receive cannot proceed; a send waits and completes once the primary is back — needs control of the primary

Automated Checks

  • updated ReceiveLiquidityDecisionTests.swift — a primary covers fixed amounts without channels and needs no extra liquidity action

Evidence

  • Release builds for device with the app, notification and widget targets; the test target compiles (build-for-testing). Unit tests run on CI only, since this machine runs no simulators.

  • The relay carries Lightning: a second Beignet node completed the BOLT 8 handshake with the primary through bore.pub:39101.

  • Regtest run with recordings: feat: lightning-first primary node #1160 (comment)

Gaps

  • On regtest at d4d1eba, cases 1, 2, 3, 5, 6 and 8 (send half) pass; 4 and 7 were not run.
  • On mainnet, every case waits on the funded primary and both phones.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: Not exposed)
  • Implementation: claude-opus-5-5 (reasoning: Not exposed)
  • Review: Not performed

@ovitrif

ovitrif commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

QA:
Tested at d4d1eba on regtest: iOS 26.5 simulator (iPhone 17 Pro), E2E debug build with PRIMARY_NODE_URI set to a regtest Beignet 0.35.1 primary (liquidity provider on, zero fees). bitcoind and Electrum came from bitkit-docker. A second Beignet wallet with a 2,000,000-sat channel into the primary paid and was paid as the outside wallet. The build of pair PR synonymdev/bitkit-android#1728 ran beside it on an Android 15 emulator for Phone to phone.

🟢 Receive on an empty wallet, Send from a lightning: link, Receive more than the channel holds (splice-in), Transfer to savings, Phone to phone, Send while the primary is offline
Receive on an empty wallet

On an empty wallet, Receive › Spending › Receive Lightning Funds opens the amount editor. A 20,000-sat invoice comes from the primary, the outside wallet pays it, and Received Instant Bitcoin shows. The primary opened a 30,000-sat channel with zero confirmations.

i1-receive-empty2x.mp4

Send from a lightning: link

A 5,000-sat invoice from the outside wallet, opened as a lightning: link, pays through the primary and Bitcoin Sent shows.

i2-send2x.mp4

Receive more than the channel holds (splice-in)

With about 15,000 sats of inbound, a 25,000-sat receive goes through the primary. The primary spliced 35,000 sats into the same channel (30,000 to 65,000) and forwarded the payment; no second channel opened.

i3-receive-splice2x.mp4

Transfer to savings

Spending › Transfer To Savings moves the spending balance to savings. The channel closed cooperatively and the close confirmed on chain.

i7-transfer-to-savings2x.mp4

Phone to phone

The Android build of pair PR synonymdev/bitkit-android#1728 pays a 10,000-sat invoice from this build. Both phones are clients of the same primary, which forwarded the payment between their channels. Android left, iOS right, time-aligned.

p2p-side-by-side.mp4

Send while the primary is offline

With the primary stopped, paying a 1,000-sat invoice from the Android build waits on Connecting to Network, since the primary is the wallet's only peer. Once the primary is back, the wallet reconnects, shows the confirm screen, and the payment completes.

i5-primary-offline2x.mp4

Findings:

Not run:

@ovitrif
ovitrif force-pushed the feat/lightning-first-primary branch from d4d1eba to 07e5199 Compare October 29, 2026 03:41
@ovitrif

ovitrif commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Closing, I won't be exploring this direction via these PRs.

@ovitrif ovitrif closed this Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant