Skip to content

Lightning network support - #1896

Open
gemcoder21 wants to merge 54 commits into
mainfrom
hm/spark-lightning
Open

gemcoder21 wants to merge 54 commits into
mainfrom
hm/spark-lightning

Conversation

@gemcoder21

@gemcoder21 gemcoder21 commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Adds Bitcoin Lightning as a coin on iOS and Android, through Spark with Breez's spark-wallet crate called directly from Core. Design: docs/LIGHTNING.md.

  • Coin: Chain::Lightning (asset lightning), phrase wallets only, off by default and turned on in Manage. The account address is the Spark address of the identity key m/8797555'/1'/0'.
  • Units: the asset is counted in sats (symbol sats, 0 decimals). It shares Bitcoin's price rows, divided by AssetId::price_scale wherever a price, chart or candle is served for it.
  • Receive: one screen with a QR of an any-amount BOLT11 invoice, copy and share; there is no amount field. The QR holds the invoice in uppercase so it encodes in the denser alphanumeric mode. The screen polls until the invoice is paid, and replaces it when it expires.
  • Send: the standard flow with a BOLT11 invoice as recipient, pasted or scanned (lightning: prefix optional). The signer signs the Spark address, invoice, amount and quoted fee as typed JSON with the keystore key, so a send asks for authentication like any other coin, and broadcast pays only when that signature verifies against the open wallet's identity key.
  • Keys: the mnemonic never crosses UniFFI. Turning Lightning on derives the account key m/8797555'/1' in Core and stores it in GemSecureStore (Keychain, when unlocked, this device only; the Tink-encrypted store on Android) without biometrics. Balance, receive, claiming and sync open the wallet from that key without prompting, and a session stays open until the wallet is deleted. The key is removed with the wallet's secrets and is never exported.
  • Storage: Spark keeps its leaves in memory and reads them from the operators on each open. The only stored Lightning state is a record of each send (transfer id, invoice, amount) in the wallet's preferences, so a retry finishes the same transfer instead of paying twice. No database table, and Core links no SQLite.

Trade-off to review: the stored account key is a hot key. Anyone who can read the app's secure storage on an unlocked device can spend the Lightning balance, even when the secret phrase is protected by biometrics. It cannot reach the phrase or any other coin.

Cost for a wallet that never turns Lightning on:

Lightning off Lightning on
Spark sessions and operator requests none opened on foreground and on demand, kept open
Stored key none account key in secure storage until the wallet is deleted
Stored state none one preferences entry holding send records

Tested: CI lint, schema check and unit tests. Mainnet receive and send were verified on an earlier storage design; they have not been repeated since the stored key, preferences store and sats changes, and Android has not been run on an emulator.

Size: 4,259 added lines in 145 files, of which 1,287 are Cargo.lock (the dependencies of spark-wallet). Hand-written code, tests and docs are about 2,970 lines.

Not in this PR: Lightning Address, LNURL, bitcoin:?lightning= URIs, Spark-address sends, on-chain deposit and withdrawal.

  • Backend LN indexing, push notification
  • QR Code parse LN invoice
  • Swap to LN

0xh3rman and others added 30 commits November 13, 2026 08:41
* Core adds Chain::Lightning, its own chain type, with the asset Bitcoin Lightning (BTC, 8 decimals, so base units are sats). Phrase wallets derive the account through the usual missing-chain setup; the address is the Spark address of m/8797555'/1'/0'. It is off by default, takes the Bitcoin price, and is left out of device subscriptions and private-key import.
* gem_spark wraps Breez's spark-wallet with no API key. The mnemonic stays in GemKeystore, the signer drops its key on lock, and the address and invoice code build without the wallet stack, so the backend links none of it.
* Spark reads need the identity key, so Core keeps open wallets in a session registry. The Lightning provider reads balance, payments, fee quotes and payment status from it; a locked wallet keeps its stored balance instead of failing the refresh.
* Sends use the standard flow: an invoice with an amount goes straight to confirm, an amountless one to the amount screen. The signer signs the invoice, amount and quoted fee with the identity key, and broadcast pays only when that signature matches the open wallet.
* Transaction rows show Lightning amounts without the dust cutoff and without a counterparty.
… through the standard flow

* The unlock context is kept for the unlocked period and cleared on relock. Only the Lightning password provider reads the keystore password with it, so opening Lightning needs no second prompt.
* The current wallet's Lightning opens on wallet refresh and on return to the foreground when it is enabled, and every move to the background locks all sessions.
* Receive on the coin opens an invoice screen with an optional amount, QR, copy and share, and closes when the invoice is paid. Send goes through Recipient, which asks for a Lightning invoice.
* The Bitcoin asset screen links to Bitcoin Lightning.
Mainnet only: no LightningNetwork or regtest paths. The receive screen's logic lives in GemLightningService.receive and receive_state, and the payment decoder reads lightning: and bare invoices so recipients take the generic path. Lightning checks move to their owners: Chain::is_indexed for subscriptions and backend sync, an empty counterparty drops the row subtitle, the Bitcoin screen's Lightning row comes from asset details through a generic GemRowAction::Asset, and the backend alone prices Lightning. Operator sessions use the SDK's in-memory store. Opens read the lock generation before any await, a send that fails after commit returns its transfer id, a retry with another amount is refused, and a running send is never resumed or dropped. CI installs protoc for the Spark SDK.
…im unused API

A send whose commit check fails, or whose retry cannot resume, returns its transfer id instead of an error, and the record of a successful send stays until a sync confirms it; a status check reads a missing transfer with no record as failed. open_enabled reads the current wallet itself, the invoice debounce is a Core constant, and the unused sync export, invoice fields, provider payments path and invoice fuzz target are gone.
… forwarding API

resume deletes a pending-send record only when the operators have no such transfer or it failed, or once the invoice expires, so confirming the same invoice again keeps returning the first transfer. The provider asks for the send fee instead of a whole quote, open_enabled and the wallet lookup reuse the session helpers, and the Lightning decoder test moves to the URL router.
# Conflicts:
#	android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/asset/models/AssetAction.kt
…one except Spark's own client

The wallet node check has no Lightning node to probe, so it asks for the basic check and the test skips it. The CI check that keeps reqwest out of gemstone prunes Spark's platform-utils, which brings its own HTTP client; LIGHTNING.md records that Spark traffic bypasses the native provider and that release builds need protoc. Lightning error conversions live with the other error conversions, a bad stored transfer id reads as InvalidTransfer, the broadcast classifier uses the trait default, and the gemstone iOS recipe links the system SQLite like generate-stone.
# Conflicts:
#	android/app/src/main/kotlin/com/gemwallet/android/di/DataModule.kt
#	core/gemstone/justfile
1. StoredTreeStore implements Spark's TreeStore over GemLightningStore: Spark's in-memory store keeps the selection and reservation rules and each changed leaf is written through with its exit chain
2. Pending sends move to the same store, so rusqlite and spark-sqlite leave Core and wallets no longer have a spark.sqlite file
3. LIGHTNING.md describes only the current design
1. lightning_leaves and lightning_pending_sends tables keyed by wallet id and deleted with the wallet
2. GemstoneLightningStore hands LightningStore to Core in place of a storage directory
3. Gemstone no longer links the system SQLite for Spark
1. lightning_leaves and lightning_pending_sends tables keyed by wallet id and deleted with the wallet
2. GemstoneLightningStore hands LightningDao to Core in place of a storage directory
1. Spark uses its default in-memory tree store, so StoredTreeStore and the leaf half of GemLightningStore go
2. LIGHTNING.md: leaves are read from the operators on each open and exit chains are not kept on the device
# Conflicts:
#	core/crates/primitives/src/wallet_connect_namespace.rs
#	core/gemstone/src/services/recipient/rules.rs
@gemcoder21
gemcoder21 marked this pull request as ready for review November 14, 2026 02:34
…store synchronous and drop the transfer fuzz target
…state

Balance, fee, send, status and payment sync open a wallet from its stored
key when no session is open. Turning Lightning on in Manage derives and
stores the key, and the foreground open reads only the stored key. A wallet
without a stored key fails as cancelled, and the receive screen keeps
Unlock for that case.
Receive no longer takes an amount: it returns an any-amount invoice and
drops the amount field and the invoice debounce constant. The QR holds the
invoice in uppercase so it encodes in the denser alphanumeric mode; copy
and share keep the lowercase invoice.
Spark stops its background tasks when the wallet drops, so the extra
cancel channel, Drop impl and tokio dependency go. Pending sends derive
expiry from the stored invoice, the transfer digest lives on the transfer,
TransferId is parsed once, and LightningError::from_display replaces the
repeated string conversions. Smaller cuts: inlined single-use helpers,
one key-name helper, shared network row, unused mocks and test lines.
The Lightning asset is now symbol sats with 0 decimals, so balances,
activity and amounts read in whole sats. The backend shares Bitcoin's
price rows and divides by AssetId::price_scale, derived from the two
chains' decimals, wherever it serves a price, past price, chart, portfolio
chart or candle for the asset. Scanned invoices route to confirm with
their amount, or to the amount screen when amountless.
The asset chart and the portfolio chart divide by AssetId::price_scale
inside the multiplications they already do, so the scaled_charts helper
is gone. Candles reuse the app's candle rate conversion, moved into
ChartCandleStick::with_rate, in place of a second scaling copy. Past
prices are no longer scaled, since Lightning activity never comes from
the backend.
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.

2 participants