Skip to content

feat: pay contacts via spark - #1754

Open
ovitrif wants to merge 116 commits into
masterfrom
feat/paykit-spark-route
Open

ovitrif wants to merge 116 commits into
masterfrom
feat/paykit-spark-route

Conversation

@ovitrif

@ovitrif ovitrif commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1807
Closes #1850

Twin: synonymdev/bitkit-ios#1191
Dependency:

This PR routes every bitcoin Paykit contact payment through bitkit-core's Paykit router, so a wallet whose spending balance sits on Spark pays contacts directly over Lightning, or through a Spark withdrawal to a contact who only accepts on-chain bitcoin.

Description

  • Asks bitkit-core's router (paykitRoutePayment) how to pay every bitcoin contact payment: on the amount screen's Continue, when a payment request's Confirm opens with its fixed terms, and when the source is switched on Confirm. The router gets the contact's endpoints and what the selected source can spend:
    • Spending: all endpoints, the Lightning spendable (Spark included), and a Spark withdrawal leg for each on-chain endpoint.
    • Savings: the on-chain endpoints and the on-chain spendable.
    • USDT, hardware wallets and LNURL-withdraw keep their own flow.
  • Acts on the router's answer:
    • Direct pays the endpoint it names: Lightning (bolt11 first, then LNURL) or the on-chain address.
    • Service opens Confirm with Route: Spark and the leg's fee in the fee row. The swipe withdraws exactly the amount to the contact's address, so the contact receives exactly the amount entered.
    • None shows "Insufficient balance" when the amount is above what the source can spend, else "Can't pay this contact" (translated in every locale). The router's reason goes to the log only.
  • Quotes a Spark leg of exactly the amount at medium speed, with Spark's fee and its L1 broadcast fee on top, only when the Spark balance covers both (SparkRepo.withdrawalRouteLeg). The withdrawal sends only at the debit Confirm showed: a quote that rose since fails with a fee-changed error, and nothing is sent.
  • Offers Spending as a source for an on-chain-only contact while Spark can pay, also when Savings holds some funds but not enough. A request's Confirm opens from Savings when it covers the amount, else from Spending, where the Spark leg can pay it.
  • Pays an on-chain payment request from Spark: the withdrawal goes to the request's address, the request's proof carries the withdrawal's txid, and the proof is checked right before the withdrawal sends.
  • Counts a Spark leg's debit and fees in Confirm's warnings, and saves the tags picked on Confirm on the withdrawal's activity, also on a recovered one.
  • Records a Spark withdrawal to a contact as an on-chain Sent activity with the contact, the amount and Spark's fee, never as a transfer. The Spark screen lists only real transfers under Transfer to Savings.
  • Journals each withdrawal before it sends, so one the app was killed during settles from Spark's payment list on the next start, as does a request proof whose completion was cut short. A second withdrawal waits until the first settles. Withdrawals Spark already listed to the same address never settle a new attempt, and a request proof whose withdrawal Spark shows never sent is released, so the request can be paid again.
  • Builds on bitkit-core 0.8.0-spark.3, a prerelease of feat: pay contacts via spark bitkit-core#193. No build flag.

What it needs to ship

  • Spark leg on a device: a restore while a withdrawal is in flight is not run on a device.
  • Fees: Confirm shows the leg's fee under the Lightning "Instant" label, which reads as a Lightning fee.
  • Merge order: the dependencies above first; this branch stacks on them.

Out of Scope

  • Cancelling a sent one-time payment request: not supported on Android on either rail, tracked separately.
  • A Spark-native Payment Endpoint: the Payment Endpoint Identifier spec has no Spark rail (section 5.2).

Design

N/A — no design available.

Preview

Two regtest Android emulators (896×1994, dark mode) with Spark on Lightspark's hosted regtest and a local Pubky homeserver, one recording per flow at 2x speed; A is Alice and B is Bob, each other's contacts, both Spark only with no channel.

1 and 2: A pays B 300 over B's private endpoint through the router; then B's app is closed while A pays 250, and B's relaunch shows it received from Alice.

1. Private endpoint, payer A 1. Private endpoint, payee B
1754-p1-a2x.mp4
1754-p1-b2x.mp4
2. Payee closed, payer A 2. Payee closed, relaunched, payee B
1754-p2-a2x.mp4
1754-p2-b2x.mp4

3: A pays the on-chain-only fixture contact 2,000 through a Spark withdrawal (Route: Spark, 2,190 fee); 4: A requests 200 and B pays it from Spark.

3. On-chain-only contact via Spark, payer A 4. Request paid from Spark, payee A 4. Request paid from Spark, payer B
1754-p3-a2x.mp4
1754-p4-a2x.mp4
1754-p4-b2x.mp4

Flow 4 runs with the roles the other way round from the iOS twin: here A requests and B pays.

5: A requests 2,000, B (the payer) dismisses it from Payment Requests, and A's request reads Rejected.

5. Request rejected, payer B 5. Request rejected, payee A
1754-p5-b2x.mp4
1754-p5-a2x.mp4

6: B's allowance ($1 a payment) auto-pays A's 300 request from Spark and keeps the 2,000 one manual; ended: after B ends it, A's 200 request stays manual.

6. Allowance, allower B 6. Allowance, payee A
1754-p6-b2x.mp4
1754-p6-a2x.mp4
6. Allowance ended, allower B 6. Allowance ended, payee A
1754-p6e-b2x.mp4
1754-p6e-a2x.mp4

7: B proposes a 100-sat daily subscription, A subscribes and pays the first period from Spark, and it is still active after A relaunches; 8: B requests $0.20 and A pays it at its quote from Spark.

7. Subscription, subscriber A 7. Subscription, creator B
1754-p7-a2x.mp4
1754-p7-b2x.mp4
8. Fiat request, payer A 8. Fiat request, payee B
1754-p8-a2x.mp4
1754-p8-b2x.mp4

9: B turns private contact payments off (A can't pay) and on again (A pays 100); 10: an amount above the balance can't be entered, and the sendable maximum pays; 11: A is killed right after the swipe, and nothing is paid twice after the relaunch.

9. Contact payments off and on, payee B 9. Contact payments off and on, payer A
1754-p9-b2x.mp4
1754-p9-a2x.mp4
10. Above the balance, then the maximum, payer A 10. Sendable maximum, payee B
1754-p10-a2x.mp4
1754-p10-b2x.mp4
11. Kill mid-payment, payer A 11. Kill mid-payment, payee B
1754-p11-a2x.mp4
1754-p11-b2x.mp4

Screens

Dev debug build on a regtest Android emulator (896×1994 at 320 dpi), dark mode, Spark on Lightspark's hosted regtest; every state was reached in the flows above, with no debug hooks.

Confirm
Lightning contact, paid from Spending On-chain contact, Route: Spark No route
Activity
Contact payment via Spark On-chain contact payment via Spark
Spark screen
Transfers list without contact payments

QA Notes

Setup

  • Pubky stack: synonymdev/bitkit-docker main with the marketplace and payment-requests profiles; its fixture-issuer is the on-chain-only contact (J1, Preview 3).
  • App: two dev debug builds on separate emulators, built with E2E=true E2E_BACKEND=local E2E_LOCAL_HOST=127.0.0.1 E2E_HOMESERVER_PUBKY=$(./pubky-marketplace info | jq -r .homeserver_z32), with adb reverse 6286, 6287, 6288, 15411, 15412, 60001, 23735, 23000 and 23010.
  • Wallets: each a new wallet with no channel, a Pubky profile on the local homeserver, and the other saved as a contact (bitkit://contact?pubky=<pubky>).
  • Spark: dev builds run Spark on Lightspark's hosted regtest. Fund Spending by a Spark payment from another funded regtest Spark wallet to an amountless invoice from Receive › Spending, or from Lightspark's web regtest faucet to the deposit address on Settings › Advanced › Lightning Connections › Spark.
  • Two chains: the node and Spark run on different regtest chains (staging's for cases 1–13), so a Spark withdrawal to a contact's address (case 11, J1) lands on Lightspark's chain and never shows in the payee's Savings; check it on Lightspark's regtest mempool.

Journeys

  • J1: new paykit-spark-route.xml — a wallet whose spending balance is on Spark pays an on-chain-only contact through a Spark withdrawal, and the contact receives exactly 20,000 sats

Manual Tests

Two contacts as above, both Spark only (no channel); either one can play payer or payee.

  • 1. Pay the contact from Spending, 300 sats: the payee's private endpoint is paid from Spark; the payer's activity shows the contact, the fee and the preimage; the payee's receive is linked to the payer.
  • 2. Pay again after the payee relaunched: the payee's private endpoint was rotated and republished, and the payment uses the new list version.
  • 3. The payee is closed while it is paid: on reopen it shows the receive linked to the payer, with no duplicate.
  • 4. The payee requests 200 sats (Contact › Pay › Request): the payer pays it from Spark, and the payee's request shows "Proof submitted".
  • 5. The payer dismisses a request: both sides show it as Rejected, and nothing is paid.
  • 6. The payer offers the payee an allowance of $1 a payment: a 300-sat request is paid automatically from Spark and the allowance shows what was paid; a request over $1 stays manual; after the allowance ends, a request stays manual.
  • 7. The amount pad refuses more than Spark can send; the largest amount it accepts pays with room for the fee.
  • 8. Kill the payer right after the swipe: on relaunch nothing is paid twice, and an unsent request is still payable.
  • 9. The payee proposes a daily 100-sat subscription; the payer subscribes: the first period is paid from Spark, and after a relaunch the subscription is still active with its next renewal.
  • 10. The payer has Spark and Savings: Spending is the default source; switching to Savings pays the contact's on-chain endpoint, which is the payee's Savings address, and the payee's receive is linked to the payer.
  • 11. The payee requests 2,000 sats accepting Savings only; the payer, with Savings and Spark, switches the request's source to Spending: Confirm shows Route: Spark with the withdrawal's fee, the withdrawal pays exactly the amount to the payee's address, and the payee's request shows "Proof submitted".
  • 12. The payee requests 200 sats accepting both: the payer's Confirm opens directly from Spending and pays over Lightning.
  • 13. Turn the payer's network off on Confirm and swipe: the payment fails with the payment-failed screen before anything is sent, and nothing is paid after reconnecting.

Evidence

Dev debug builds on the staging backend, on two regtest Android emulators (API 35), Spark on Lightspark's hosted regtest, no channel. Both wallets were Spark only, except in cases 10 and 11, where the payer's Savings was funded with ./lsp POST /regtest/chain/deposit. Each line names the head it ran at.

  • 1 at 1cbb4b6c9: 300 sats paid; the payer showed Sent 301 (fee 1) to the contact with its preimage, and the payee showed Received 300 from the contact. Through the router, Direct bolt11: 120 sats at bd755b839, and 110 sats at 25b40e0fd on bitkit-core 0.8.0-spark.3.

case 1
case 1 on spark.3

  • 2 at 1cbb4b6c9: after the payee's relaunch the payer opened list version 5 after 0 and paid 150 sats.
f11-rotate2x.mp4
  • 3 at 1cbb4b6c9: the force-stopped payee received 250 sats; on relaunch Home showed Received 250 from the contact and no duplicate rows.

case 3 payer
case 3 payee

  • 4 at 1cbb4b6c9: a 200-sat request was paid, and the payee showed "Proof submitted".

case 4 payee
case 4 payer

  • 5 at bd755b839: both sides showed Rejected.
f5-reject-payer2x.mp4
  • 6 at bd755b839: the 300-sat request was paid automatically ("Paid automatically $0.23"); a 2,000-sat request was kept manual ("the allowance limit is reached"); after End Allowance, a 200-sat request was presented for manual payment.

case 6 payer
case 6 payee

  • 7 at 1cbb4b6c9: with 4,473 sendable, 6,000 and 44,731 could not be entered; 4,473 was paid, quoted at ± ₿ 12.
f9-above2x.mp4
  • 8 at bd755b839: two kills before Spark dispatched left the balance unchanged and the request payable; a kill after dispatch is Flow 10 below.

case 8
case 8 relaunch

  • 9 at 25b40e0fd: the first 100-sat period was paid from Spark and its proof queued; after a force-stop and relaunch the payer listed the subscription as Active, renewing the next day. Later periods not observed yet.
  • 10 at 25b40e0fd: from Savings, the router paid the payee's on-chain endpoint 15,000 sats at Normal (₿ 141); the payee showed Received 15,000 from the contact in Savings.
f4-onchain2x.mp4
  • 11 before 47a1484a8: switching to Spending showed Route: Spark; Spark's withdrawal 365dc1df… paid exactly 2,000 sats to the payee's address and confirmed on Lightspark's chain (the payee's node runs on staging's chain, so its Savings doesn't show it); the proof was delivered and the payee showed "Proof submitted"; the payer's activity showed the contact. Confirm quoted ± ₿ 750 while Spark charged 2,190, and the activity read as a transfer: 47a1484a8 fixes both.
f5-onchain-payer2x.mp4
  • 12 at 47a1484a8: Confirm opened from Spending at Instant (± ₿ 1); 200 sats sent and received.
f5-router-request22x.mp4
  • 13 at 47a1484a8: Spark refused the send before it left ("Spark could not prepare the payment"), the payment-failed screen showed, and after reconnecting no payment had been made.
f9-offline2x.mp4
  • J1 at 28a63f8 on bitkit-core 0.8.0-swaps.3, before the router: Spark's withdrawal 56263be8… paid exactly 20,000 sats to the contact. Not rerun since.

  • Flow 3 at 0f5d7e531, on a dev build with E2E_BACKEND=local and ESPLORA_URL on Spark's chain, payer with Spark and Savings: the payee is bitkit-docker's fixture-issuer, which publishes only a btc-regtest-p2wpkh endpoint, since Android has no Lightning-only sharing toggle. From Spending, Confirm showed Route: Spark at ± ₿ 2,190. The swipe sent 2,000 sats, Spending dropped from 14,901 to 10,711 (2,000 plus the 2,190 shown), and the activity reads "Sent to" the contact with a 2,190 fee, not a transfer. Spark's withdrawal 413332e8… paid exactly 2,000 sats to the payee's address.

flow3-onchain-contact-via-spark.mp4

  • Flow 10 at 0f5d7e531, same build: bitkit-docker's rc56-peer requested 2,000 sats on-chain, the payer paid it from Spending through Spark (Route: Spark), and the app was force-stopped the moment it logged Sent Spark withdrawal. After the relaunch, the new process submitted the request's proof and the request reads "Proof submitted" (ProofSubmitted on the peer). Spending was debited once, from 10,711 to 6,521, and Spark's withdrawal f5556e33… is the only transaction to the payee's address. The 2,000-sat requests shown after the relaunch are two other requests, still open. ⏭️ The allowance: allowance auto-pay pays only Lightning invoices (PaykitAllowanceExecutor), so an allowance never reaches a Spark withdrawal.
10. Kill after dispatch, payer 10. Relaunch, payer
flow10-kill-after-dispatch.mp4
flow10-relaunch.mp4

Paykit flows with a Spark balance

Flows from the v0.1 specs at pubky/paykit-rs cb8cba5. State a is Spark only, with no channel. State b is a channel too small for the amount, with Spark covering the rest. The case numbers above give the evidence.

  • State b, every flow: ⏭️ not staged. With about 9,000 sats on Spark, any channel the wallet can open has more outbound than Spark, so the node pays, and one payment can't split across a channel and Spark. Unit tests cover the Spark choice when a channel can't send (LightningRepoTest, PaykitAllowanceRepoTest).
    1. Pay a contact's Lightning endpoint: ✅ private endpoint (cases 1, 2), also through the router on 0.8.0-spark.3; ⏭️ public endpoint, because both test wallets hold a private link, which is paid first.
    1. Amountless Lightning endpoint: ✅ contact endpoints are amountless invoices (case 1).
    1. On-chain-only contact through a Spark withdrawal: ✅ the leg debits the 2,190 fee Confirm shows, the activity reads as a send to the contact, and the payee receives exactly the amount (Flow 3 above).
    1. Contact with both endpoints: ✅ Lightning from Spending by default; switching to Savings pays on-chain (case 10).
    1. Payment requests as payer: ✅ pay over Lightning (cases 4, 12), pay on-chain from Spark (case 11), reject (case 5); ⏭️ cancel before payment (not supported on Android on either rail, tracked separately), expired or past the deadline (not staged).
    1. Recurring requests and subscriptions: ✅ the first period paid from Spark, and the subscription survives a relaunch (case 9); ⏭️ later periods.
    1. Allowances: ✅ auto-pay from Spark with the ledger updated, over the limit kept manual, an ended allowance doesn't pay (case 6).
    1. Payment conversion: ⏭️ fixed-rate fiat requests are rail-agnostic; Bitkit never issues per-period quotes.
    1. Balance edges: ✅ above Spark refused at input, the sendable maximum paid with fee room (case 7), Spark unreachable at send fails before anything is sent (case 13).
    1. Kill mid-payment: ✅ a kill before Spark dispatched pays nothing and the request stays payable (case 8); ✅ a kill after dispatch pays once and the request reads "Proof submitted" after the relaunch (Flow 10 above).
    1. Spark-backed Lightning endpoint: ✅ private endpoint, rotated and republished after relaunch, linked to the contact (cases 1, 2, 3); ⏭️ public endpoint.
    1. On-chain endpoint: ✅ it is the Savings address, as Bitkit intends (per-contact reserved addresses), never Spark's deposit address; the receive is linked to the contact (case 10).
    1. Request as payee: ✅ Lightning (case 4) and on-chain (case 11), proof submitted; the on-chain receive is shown by the transaction on Lightspark's chain, since the payee's node runs on another chain.
    1. Recurring payments received into Spark: ✅ the first period (case 9); ⏭️ later periods.
    1. Allowances as payee: ✅ (case 6).
    1. Cancel a sent request: ⏭️ not supported on Android on either rail, tracked separately.
    1. Lightning sharing toggle: ⏭️ Android has only the whole-sharing toggle, no Lightning-only one.
    1. Payee closed while paid: ✅ (case 3).
    1. Spark-native endpoint: not in spec; the Payment Endpoint Identifier spec has no Spark rail (section 5.2; extensions in section 8).

Automated Checks

  • added AppViewModelSendFlowTest cases: router Direct Lightning, Direct on-chain from Savings, Service leg, None with a short balance and None with an unpayable contact; the Spark leg runs only from Spending; a request's Confirm pays the endpoint the router picks; an on-chain request paid through the Spark leg starts and completes its proof, and a failed leg releases an unsent proof; a source switch on Confirm reroutes; Spark leg fees count in the warnings; Confirm's tags reach the withdrawal; an on-chain request Savings can't cover goes to Spark
  • added a PaykitPaymentStateBackupTest case: a Spark withdrawal proof keeps its rail through backup
  • added PaykitPaymentProofRepoTest cases: an on-chain proof completed with a Spark withdrawal's txid, one settled from Spark's payment list on reconcile, and one released when its withdrawal never sent
  • added SparkRepoTest cases: a withdrawal quoted as a route leg, no leg the balance cannot cover, a withdrawal recorded with its contact and fee, a second withdrawal refused while one is unsettled, a journaled withdrawal settled or dropped on start, a withdrawal refused right before it sends or above the confirmed debit, the journal kept when the activity fails to save, an earlier withdrawal never settling a new attempt, tags on a recovered withdrawal, a contact withdrawal not marked as a transfer, and kept off the Spark screen's transfers
  • ran just compile, just test, just lint and the androidTest compile on bitkit-core 0.8.0-spark.3: pass

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.

Round 1

None yet: the flows above ran on regtest.

Round 2 (pending)

On the final heads, two mainnet wallets that are each other's Paykit contacts, small amounts.

  • Spark only, no channel: pay a contact's Lightning endpoint, both ways; Activity shows the contact and the fee.
  • A channel too small for the amount: the payment goes from Spark.
  • A contact with only an on-chain endpoint: paid through a Spark withdrawal, the fee includes the on-chain fee, and Activity shows the contact.
  • A payment request paid from Spark, over Lightning and on-chain: the payee's request shows paid and the proof matches.
  • An allowance: a request within it auto-pays from Spark, and the ledger shows it.
  • Receive: a contact pays our Spark-backed endpoint while the app is closed; on reopen it's linked to the contact and the endpoint has rotated.
  • Kill the payer mid-payment: on relaunch it settles once, with no double pay.
  • An amount above Spark plus the channel: refused with the app's copy, and nothing is spent.

Models used

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

Taken from the run logs of every session and subagent that planned or wrote this PR, which record the model and reasoning effort per turn. The claude-sonnet-5-5 planning runs were research and spec-mapping subagents. No independent agent review was run on this PR.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 036346a: adds the paykit-spark-route.xml journey (J1). Ran it on a regtest Android emulator against Lightspark's hosted regtest. The router picked a Spark withdrawal, and 56263be8… pays exactly 20,000 sats to the contact. The recording, setup and evidence are in the description.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from b7c748f (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@ovitrif ovitrif added this to the 3.2.0 milestone Nov 3, 2026
@ovitrif

ovitrif commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Added to the 3.2.0 milestone: Spark via Breez SDK is the 3.2.0 highlight, and this PR stacks on #1697.

@ovitrif ovitrif changed the title feat: pay on-chain contacts from spark feat: pay contacts via spark Nov 3, 2026
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from ab34526 to ab1b09d Compare November 5, 2026 02:00
Rebuilt on the split Spark head; carries the Pubky Swaps route (#1732) as it was merged into this branch.
@ovitrif
ovitrif force-pushed the feat/paykit-spark-route branch from c08023a to f00fb41 Compare November 5, 2026 02:10
@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Force-pushed: rebuilt on #1697's new head as one commit, f00fb41, with the same route changes. The previous head, c08023a, is kept on backup/paykit-spark-route-20261105.

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips your two blocking threads are fixed in d1b899a: a Savings payment to a mixed contact now sends on-chain without fetching from its LNURL. 5042f41 had only fixed the Continue check, not the swipe.

Spark can dispatch after the request deadline during the uncertainty write

Fixed in #1858, where this code comes from (it merged to master with #1697). There the uncertainty write comes before the last deadline check, and a refusal clears both holds. Its test is a spark send whose deadline passes while its uncertainty is written never leaves.

@pwltr

Medium — Clear the Spark withdrawal fee when rerouting to a direct LNURL.

Fixed in b43fae9. A Direct LNURL pick clears lightningFeeSats. So after a Spark leg, going back to an amount the LNURL accepts no longer shows the withdrawal fee on Confirm.

@jvsena42 both LOWs are fixed here (d1b899a, 315cd03 and ba94a58); see their threads.

This push also ports the fixes from the iOS twin's latest review (synonymdev/bitkit-ios#1191):

  • eb6e8e1: an open contact withdrawal no longer lists as a transfer to Savings, even before its send is written.
  • 6370c37: the 0.28.0 fee-rise InvalidInput, which Spark raises before it withdraws, closes the journal at once as SendRefused, like InsufficientFunds.
  • a5022fe: while a contact withdrawal is still settling, no Spark leg is quoted, and Amount says an earlier withdrawal is still settling instead of reaching Confirm with Route: Spark.

Each fix has a test that failed first. Compile, the full unit tests, detekt and lint pass.

re-review is needed only because of this push: 5042f41...ba94a58

@ovitrif
ovitrif dismissed stale reviews from jvsena42, pwltr, pwltr, jvsena42, jvsena42, pwltr, pwltr, pwltr, pwltr, and pwltr November 11, 2026 16:11

Addressed in 5042f41...ba94a58; each thread names its fixing commit.

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI failed at ba94a58 because master moved to bitkit-core 0.8.0-rc14, whose router request has two new fields and whose route type has a new Swap case. This push fixes it:

  • f2654b0 merges master, with no conflicts.
  • 1f1a42e passes providers = emptyList() and feeRateSatVb = 0 (PaykitRouteRepo.kt:58-60). rc14's paykit_route_payment forwards to route_payment, which reads neither field; only the Pubky Swaps entry points do.
  • A Swap route takes the no-route path (AppViewModel.kt:4859). With no providers, the router can't return one.

Compile, the full unit tests (5,470), detekt and lint pass.

re-review is needed only because of this push: ba94a58...1f1a42e

@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 eight own commits since 5042f416f (head 1f1a42e0f): my three earlier LOWs are fixed, two new LOWs inline, approving.

  • No LNURL fetch for an on-chain pick (d1b899a97): isLnurlPay now needs payMethod == LIGHTNING and no service leg (AppViewModel.kt:6604-6605).
  • Confirm layout (315cd03bf, ba94a584e): paysLnurl drives the title and layout, so an on-chain pick shows OnChainDetails (address, miner fee, speed) and a leg shows LightningDetails with the Spark route and fee. That also covers the master behaviour in my out-of-scope note.
  • bitkit-core rc14 (1f1a42e0f): PaykitRouteRepo sets providers = emptyList() and feeRateSatVb = 0. The app calls only paykitRoutePayment, which never returns Swap, and a Swap would be refused with the None branch (:4859), paying nothing.
  • Fee-rise close (6370c373f): breez spark-sdk 0.28.0 raises that InvalidInput before spark_wallet.withdraw, so the journal closes with nothing in flight.
  • a5022fea8, eb6e8e1ef, b43fae99a, merge f2654b086: no issues.

The new settling string is English only, which is the Spark translation deferral agreed in #1840.

Both LOWs are display-only. They can be fixed here or moved to a follow-up issue, your choice; please say which on each thread.

Device gate: no device check needed for this delta; the reproductions are Robolectric and unit tests. CI: build-and-test, lint and detekt pass; the ui-tests and e2e shards were still running.

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

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 3815386: jvsena42's two LOWs from the last review, each with a test that failed first.

@jvsena42

  • Spark leg to an LNURL plus on-chain contact: addressed in 37276e7. Confirm shows Tags, and the LNURL comment field appears only when the payment goes to the LNURL.
  • Direct BOLT11 pick after a Spark leg: addressed in 3815386. The leg's fee is cleared, and the routing estimate fills it back in when it succeeds.
  • iOS feat: pay contacts via spark bitkit-ios#1191 has neither gap: its leg view always shows Tags and never reaches the LNURL comment screen, and its leg fee is computed from the leg each time rather than stored.

Tests: compile, detekt, lint and unit tests pass.

Re-review is needed only because of this push: 1f1a42e...3815386

@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 3815386.

Medium candidate — Keep withdrawal recording protected from a stale sync snapshot. In app/src/main/java/to/bitkit/repositories/SparkRepo.kt:783-809, the in-flight flag clears before the returned payment is recorded. For an attempt older than the ten-minute grace, a concurrent sync can use a snapshot taken before the successful send, clear its journal, and leave recordWithdrawal at :1444-1451 with neither a journal nor an existing activity. The successful contact withdrawal can then go unrecorded. Keep protection through response recording and reject stale snapshots before negative settlement.

The previously described duplicate-payment consequence is not established: recovery reads a fresh payment list, and the SDK 0.28.0 storage-before-return behavior described in the discussion would preserve that payment. The remaining candidate concerns the activity/journal race; its aged-send interleaving still needs confirmation.

Review details

Quick review
Model: Claude Opus 5.5
Reasoning effort: high

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

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

Spark lists a withdrawal completed once its provider accepts the payout, before the payout transaction is broadcast or mined (spark-sdk 0.28.0, crates/spark/src/ssp/graphql/models.rs:187-209 and crates/breez-sdk/core/src/models/adaptors.rs:339-347). A contact withdrawal's activity now stays unconfirmed until Electrum lists its payout output in a block.
A sync whose payment list was read during a withdrawal's send, or before the send ended, no longer drops the journal of an aged withdrawal, so the withdrawal Spark returned is always recorded.
@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed e0221f9 with three fixes to Spark withdrawals. Each new test failed before its fix. Compile, detekt and all 5,479 unit tests pass.

@pwltr

@coreyphillips

  • Switching to Spending on Confirm bypassing the Spark fallback (your point on synonymdev/bitkit-ios#1191): checked here, with no gap. Android re-routes on any source switch once the router has answered, in AppViewModel.kt.

Re-review is needed only because of this push: 3815386...e0221f9

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Reran the failed CI, Lint and E2E jobs on e0221f9. They failed when GitHub's artifact upload returned an HTML error page ("Failed to CreateArtifact"), during the GitHub outage. The unit tests that ran had all passed.

# Conflicts:
#	app/src/main/java/to/bitkit/repositories/SparkRepo.kt
@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c220ce5: master (with synonymdev/bitkit-android#1858) merged in. The one conflict is in SparkRepo: master's restored-send seeding sits next to this PR's journal settling in syncActivities, and both are kept. Nothing else changed.

Re-review is needed only because of this push: e0221f9...c220ce5

Master's restore tests stubbed SparkService.send with one matcher, and this branch gives send an idempotency key, so Mockito refused the stubs once both met.
@ovitrif

ovitrif commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed b7c748f, which fixes the 9 SparkRepoTest failures from the master merge. Master's new restore tests stubbed SparkService.send with one matcher, and this PR gives send an idempotency key, so Mockito refused those stubs. They now match both arguments. Only test code changed.

Re-review is needed only because of this push: c220ce5...b7c748f

This branch has not been deployed

No deployments
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.

bug: no-route toast when only the spark leg fee doesn't fit feat: pay contacts from spark

6 participants