Skip to content

feat: pay contacts via pubky swaps - #1732

Open
ovitrif wants to merge 29 commits into
masterfrom
feat/pubky-swaps-route
Open

ovitrif wants to merge 29 commits into
masterfrom
feat/pubky-swaps-route

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1826

Dependency: synonymdev/bitkit-core#182
Refs:

Description

  • Routes a Paykit contact payment through a Pubky Swaps provider when the contact's only endpoint is on-chain and savings is empty (at or below dust) while spending is not, so a wallet holding only spending can still pay that contact. The route is chosen when the contact opens, before the amount. The user never picks a swap: Bitkit asks bitkit-core's router for the route.
  • Finds providers in the user's own contact graph: every saved contact is asked for its swap offer over iroh, and those that run no provider are skipped. PUBKY_SWAPS_PROVIDERS adds extra candidates for testing.
  • Takes only signed quotes and contracts: bitkit-core negotiates over paykit-cap.* and refuses a reply not signed by the provider's key or the key its records delegate to.
  • Shows the route on the confirm screen as Route: Pubky Swaps · <provider>, with the whole swap cost in the fee row.
  • On swipe, commits the quote, pays the provider's hold invoice from spending, and waits until bitkit-core has claimed the provider's on-chain lock straight to the contact's address. The contact receives exactly the amount entered.
  • Once the hold invoice is paid, waits on it on the Pending screen, with no way back to a fresh quote that could pay twice. Shows Bitcoin Sent when the hold payment settles, which the provider does on seeing the claim, with the contact on its activity. A failed hold payment, or a swap bitkit-core failed, shows the payment error screen with Retry from Amount. The claim keeps advancing in the background for up to an hour while the app runs, and a claim not broadcast within 10 minutes leaves the send pending.
  • After a wallet restore from the seed, rebuilds the swap routes the wallet left in flight from each provider's listing, claims them to a fresh Savings address (the contact's address does not survive the restore), and tells the user the payment was returned to Savings. The restore passes the Lightning payments the node sent, so bitkit-core checks each listed lockup against what was paid. Swaps already on record are driven too, without that notice, since they keep the contact's address.
  • Behind PUBKY_SWAPS_ROUTES=true on regtest debug builds only.

Builds against the bitkit-core release 0.8.0-rc14, which includes synonymdev/bitkit-core#182. The router lets services such as Spark compete with the providers as route legs; this PR passes none, and the Spark leg comes in #1754.

Why this journey

  • Paying a contact over a rail you don't hold is the journey Pubky Swaps supports today, in its Bitcoin form: a Lightning balance pays a contact who only accepts on-chain.
  • The USDT forms (paying a USDT endpoint from bitcoin, or receiving bitcoin from a USDT payer) are blocked: Pubky Swaps is Bitcoin and Lightning only, so USDT needs a second, provider-trusted leg such as NEAR 1Click, which has no testnet (Paykit Capabilities §13.1).
  • Auto-pay on the cheapest route comes next: it is this router plus the allowance executor, which today pays only Bolt11 and on-chain, and it needs two or more providers to compare.

What it needs to ship

  • Providers: mainnet Pubky Swaps providers with Lightning and on-chain liquidity among users' contacts. Asking every contact for an offer tells each of them you are looking for a swap (no amount or destination); published Capability Descriptors would replace the probing.
  • swap/1 in Paykit: the signed paykit-cap.* negotiation runs over iroh; over Paykit Encrypted Links it needs the additive paykit-rs SDK API for extension kinds (Design review: Paykit Capabilities, a general capability and intent layer for Paykit peers (answers Paykit Intents question 8) pubky/paykit-rs#229, §17).
  • Backup of swap state: swap records are not in the wallet backup; a restore rebuilds them from providers' listings. If the app stops between paying and claiming, nothing resumes the claim yet; the funds come back through the timeouts (the provider cancels its hold invoice, or refunds its own lock). A restore made while a payment was still pending has to run again once it settles.
  • Fees: the fee row shows the provider's service fee, its on-chain cost and the wallet's claim fee under the Lightning "Instant" label, which reads as a Lightning fee. Lightning routing to the provider is on top. The claim fee rate is fixed at 2 sat/vB and should come from Bitkit's fee estimates.
  • iOS: the same flow on feat: pay contacts via pubky swaps bitkit-ios#1170.

Out of Scope

Design

N/A — no design available.

Preview

A wallet with only a spending balance pays a contact who accepts only on-chain bitcoin. The router finds a Pubky Swaps provider among the wallet's contacts, the confirm screen shows the route, the quote and contract are signed, and the contact receives exactly 20,000 sats on chain (Android emulator (API 35), 2x speed, regtest).

android-signed-swaps-route-2x.mp4

After a wallet restore from the seed, the swap the wallet left in flight before its claim is found at its provider and claimed to Savings, with the "Payment returned to Savings" notice (Android emulator (API 35), 2x speed, regtest; the phrase entry is not recorded). Recorded on bitkit-core 0.8.0-swaps.4, before restores took the node's payments.

android-restore-j2c2x.mp4

QA Notes

Setup

  • Chain: synonymdev/bitkit-docker main: docker compose up -d, then ./pubky-marketplace up and ./pubky-marketplace seed, then docker compose --profile marketplace --profile payment-requests up -d --no-build fixture-issuer rc56-peer and docker compose --profile lnurl-pay up -d --build --wait lnurl-server-fixture. A long-lived regtest chain can reach a block subsidy of 0, so start from a fresh bitcoin_home volume (docker compose down, remove the volume and ./lnd, ./lnurl-server/data) and fund with ./bitcoin-cli fund.
  • Provider: coreyphillips/pubky-swap at f45e204, cargo build --release -p swap-provider --features full. Run it on the project's LND with a throwaway BIP39 phrase in PUBKY_SWAP_RECOVERY_PHRASE__FILE: swap-provider --network regtest --directions submarine,reverse --min-amount 10000 --max-amount 1000000 --base-fee 100 --fee-ppm 2500 --confirmations 1 --wallet lnd --lnd-address https://127.0.0.1:23009 --lnd-cert <bitkit-docker>/lnd/tls.cert --lnd-macaroon <bitkit-docker>/lnd/data/chain/bitcoin/regtest/admin.macaroon --electrum-url tcp://127.0.0.1:60001 --data-dir <dir> --no-homeserver. Fund the LND on chain and mine six blocks. It prints its pubky on start, and signs with that key.
  • App: E2E=true E2E_BACKEND=local E2E_LOCAL_HOST=127.0.0.1 E2E_HOMESERVER_PUBKY=$(./pubky-marketplace info | jq -r .homeserver_z32) PUBKY_SWAPS_ROUTES=true ./gradlew assembleDevDebug. Before first launch, adb reverse 6286, 6287, 6288, 15411, 15412, 60001, 23735, 23000 and 23010.
  • Wallet: a new wallet; open the LNURL channel from curl -H 'Host: 127.0.0.1:23010' 127.0.0.1:23010/generate/channel (500,000 sats to spending); leave savings empty; create a Pubky profile; save the provider's pubky and the fixture-issuer's pubky as contacts.
  • Contact: bitkit-docker's fixture-issuer (curl 127.0.0.1:23012/health gives its pubky), which publishes only btc-regtest-p2wpkh.

Journeys

  • J1: new paykit-swap-route.xml — a spending-only wallet pays an on-chain-only contact through a Pubky Swaps provider found among its contacts, and the contact receives exactly 20,000 sats
  • J2: new paykit-swap-restore.xml — a swap route left in flight before its claim is finished after a wipe and a restore from the recovery phrase: claimed to Savings, with the "Payment returned to Savings" notice

Manual Tests

N/A

Automated Checks

  • updated AppViewModelSendFlowTest.kt — the routed send: it finishes once, as the hold payment's success, and tags the hold payment; quote, hold-payment and claim failures, and a hold-payment failure event ending the wait; a Direct answer moving to Savings; quotes from a replaced send or wallet dropped; Spending selectable for a swap-routed contact; the endpoint's method id matching its address; a stale amount or fee after edits; a late claim or failure after dismissal leaving the next send alone; a failed payment retrying from the amount with a fresh quote
  • added PubkySwapsRouteRepoTest.kt — the claim wait finishes on broadcast, retries a failed advance, and keeps advancing a swap whose wait ran out
  • updated WalletViewModelTest.kt — swap restore after a wallet restore, with the restore's failed categories; swaps already on record driven without the Savings notice
  • ran detekt and testDevDebugUnitTest for WalletViewModelTest, AppViewModel* and PubkySwaps* at 4b8c8df: pass

Evidence

  • J1 at 9f6ec3b on a regtest emulator (API 35), bitkit-core 0.8.0-swaps.9, provider coreyphillips/pubky-swap f45e204 with --no-homeserver, and no PUBKY_SWAPS_PROVIDERS, so the provider came only from the wallet's contacts (the recording above):
    • the confirm screen showed Route: Pubky Swaps · pubky…fez6y and a fee of ₿ 986 for 20,000 sats;
    • the provider's record of the swap holds the contract signed over the wallet's commit, signed by its own key;
    • the provider logged hold invoice accepted, HTLC funded; awaiting client claim and, after one block, client claimed; hold invoice settled;
    • the claim, d953772e4480f719bbf843f6df2015dd7befab453ef8d1944398f07c2abdb6c6, has one input, the provider's HTLC, and one output of exactly 0.0002 BTC to the contact's address.
    • The contact was paid as a pubky entered on the Send screen: the fixture-issuer gets a new pubky when it restarts, so the saved contact pointed at its old identity and stalled on "Encrypted Link Handshake is still in progress". That is a fixture artefact, unrelated to the route; that such a contact retries instead of failing fast is existing behaviour on master, tracked in bug: saved contact payment stalls instead of failing #1827.
  • J2 on a regtest Android emulator (API 35) at 257190c, bitkit-core 0.8.0-swaps.4, provider coreyphillips/pubky-swap main with --no-homeserver:
    • the swap was started, the provider logged "HTLC funded; awaiting client claim", and the app was killed before any block; the wallet was then wiped and restored from its throwaway regtest recovery phrase;
    • the restore listed the swap at the provider, the claim went out after one block, and the provider logged "client claimed; hold invoice settled";
    • Savings went from 0 to ₿ 20,000, the "Payment returned to Savings" notice showed, and the swap's activity turned to Sent.
    • In the first take the claim (2d3aa3d…b577) had one input, the provider's lockup, and one output of exactly 0.0002 BTC to the wallet's own address. That take also showed the notice hidden under the "Received Bitcoin" sheet the claim opens, so it now stays until dismissed (257190c).
    • The build set PUBKY_SWAPS_PROVIDERS to the provider, in case contacts had not reloaded when the restore ran; they had, and the provider was also among them.
  • J1 earlier at 46ad2a9, before quotes were signed on a regtest emulator (Android 15), with feat: pubky swaps as paykit route bitkit-core#182 at 3fd9e9d and no PUBKY_SWAPS_PROVIDERS, so the provider came only from the wallet's contacts (the recording above):
    • the confirm screen showed Route: Pubky Swaps · pubky…mpquo and a fee of ₿ 986 for 20,000 sats;
    • the provider logged hold invoice accepted, HTLC funded; awaiting client claim and, after one block, client claimed; hold invoice settled;
    • the claim transaction has one input, the provider's HTLC, and one output of exactly 0.0002 BTC to the contact's address;
    • the success screen shows the wallet's debit of ₿ 20,986.
  • Three earlier runs of the same journey, with the provider passed through PUBKY_SWAPS_PROVIDERS, paid the same way.

Gaps

  • J2 was not rerun on bitkit-core 0.8.0-swaps.9, whose restore takes the node's payments.
  • The provider's contact entry has no Pubky profile, so the route shows its key rather than a name.

Models used

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

Metadata note: the Sonnet entry is the research subagent that read pubky-swap and Bitkit's Paykit code; its exact model version is not exposed in this session's records.

@ovitrif ovitrif self-assigned this Oct 29, 2026
@ovitrif ovitrif mentioned this pull request Nov 2, 2026
22 tasks
@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 97749ce: moves to bitkit-core 0.8.0-swaps.3, whose Paykit router takes service legs (synonymdev/bitkit-core#184).

  • Passes no service legs yet, so routing behaves as before; the Spark leg comes in feat: pay contacts via spark #1754.
  • Handles the router's new Service route, which cannot be returned while no legs are passed.
  • AppViewModelSendFlowTest and just lint pass.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed f6f8ad3be: after a wallet restore, rebuilds in-flight swap routes from each provider's listing (bitkit-core 0.8.0-swaps.4, pubky_swaps_restore from synonymdev/bitkit-core#182).

  • Restored swaps claim to a fresh Savings address, since the contact's address isn't in the listing. Once a claim confirms, a toast tells the user the payment was returned to Savings. The two strings are added in every locale.
  • New WalletViewModelTest cases: swaps are restored once a wallet restore completes, and left alone while swap routes are off. WalletViewModelTest, AppViewModelSendFlowTest and just lint pass. Not covered yet: a device run of the restore path.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 257190c: the "Payment returned to Savings" notice now stays until dismissed. On a device run, the "Received Bitcoin" sheet that the claim opens covered it, and it hid before the sheet closed. Also adds the paykit-swap-restore.xml journey (J2), recorded on a regtest emulator: a swap left in flight before its claim is finished after a wipe and restore, and claimed to Savings. The recording and evidence are in the description.

@ovitrif ovitrif changed the title feat: pay contacts through pubky swaps feat: pay contacts via pubky swaps Nov 3, 2026
ovitrif added a commit that referenced this pull request Nov 5, 2026
Rebuilt on the split Spark head; carries the Pubky Swaps route (#1732) as it was merged into this branch.
# Conflicts:
#	app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
Restore passes the Lightning payments the node sent, so bitkit-core checks each listed lockup against what was paid, and drives the swaps already on record too.
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 098c355 (run).

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

@ovitrif
ovitrif marked this pull request as ready for review November 9, 2026 21:02
@ovitrif
ovitrif requested review from a team and coreyphillips and removed request for a team November 9, 2026 21:02
@ovitrif
ovitrif requested a review from jvsena42 November 10, 2026 10:55

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

Delta review of 490bdb4: no findings, and both my LOWs are fixed.

  • A swap that fails before its hold payment goes out now lands on the error screen with Retry from Amount, matching iOS.
  • The claim poll runs on the repo's app scope.

The device gate (J1/J2 with the pubky-swap provider) is still pending.

Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@ovitrif
ovitrif requested a review from ben-kaufman November 10, 2026 12:09

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

Delta review of 19f575c: no findings. A swap refused before its hold payment goes out now releases the contact's private payment list for ordinary contact sends too. The old helper only released it for requests. Nothing was paid to that address, so a retry can reuse the list.

The device gate is still pending.

Reviewer model: 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.

Delta review of cd13b13 (bitkit-core 0.8.0-swaps.9 → 0.8.0-swaps.11): no findings. This matches iOS #1170.

  • The new core refuses a swap whose id is already saved. That happens in commit_route before the hold invoice is paid, so it lands on the "refused commit → Retry from Amount" path from 490bdb4.
  • SwapRecord is unchanged and the swap FFI signatures are unchanged.
  • The wake API changes in core don't touch any app call site.

The device gate is still pending.

Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@ovitrif
ovitrif dismissed stale reviews from coreyphillips and coreyphillips November 10, 2026 13:28

Addressed in de29463, 3435c0e and 5548350; later rounds in 712cc05, 4b8c8df, 47aff38, bc8f3e8, de7b78f, 8d40a8b and c59a089.

@ovitrif

ovitrif commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips I dismissed your two changes requests, because every blocking point in them is fixed in commits on this PR, and your later reviews mark those points as addressed. The dismissal messages name the commits. The non-blocking items are tracked:

Please review cd13b13 when you can.

@ben-kaufman ben-kaufman 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.

This approval covers source review of the opt-in regtest Debug scope under the one-device assumption. The early hold-failure private-list retry remains deferred to #1837 alongside the existing retry deferrals. J1/J2 and local app checks were not run at this head, so integrated-feature and release readiness remain unverified.

Reviewer model: gpt-6.1-sol, claude-opus-5-5
Reasoning effort: gpt-6.1-sol xhigh, claude-opus-5-5 high

@jvsena42

Copy link
Copy Markdown
Member

@ovitrif this conflicts with master; please rebase. I'll review it again once it merges cleanly.

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

@jvsena42 merged master in 7c56d7a (a merge rather than a rebase, so the reviewed commits keep their shas). The only conflict was gradle/libs.versions.toml: bitkit-core stays on 0.8.0-swaps.11, which already contains 0.8.0-rc11 and 0.8.0-rc13, and paykit, ldk-node and vss take master's versions. No code changed, so a re-approval is needed only because of the base merge.

rc14 adds an optional auth argument to the regtest calls with no Kotlin default; pass null to keep today's unauthenticated calls.
# Conflicts:
#	app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
#	app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt
#	gradle/libs.versions.toml
@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Moved bitkit-core from the 0.8.0-swaps.11 prerelease to the 0.8.0-rc14 release in 9a7eebe, now that synonymdev/bitkit-core#182 is merged. rc14's regtest calls take a new auth argument with no Kotlin default, so CoreService passes null and keeps today's calls.

Then merged master in 46948b6. WalletViewModel and its test take both the swaps route repo and master's SparkRepo; the versions keep rc14 and take master's breez-sdk-spark and paykit. Nothing else changed, so a re-approval is needed only because of the dependency bump and the base merge.

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

Delta review of the rebase onto master (7c56d7a9c, 46948b65b) and 9a7eebeb3 (bitkit-core 0.8.0-rc14): one LOW, inline.

  • Core: 0.8.0-swaps.11 is an ancestor of rc14, and pubky_swaps and paykit_route are identical between them. 9a7eebeb3 only adds auth = null to the four regtest calls.
  • Route types: PubkySwapsRouteRepo.kt:83-91 sets all seven PaykitRouteRequest fields, and AppViewModel.kt:4715-4737 handles all four PaykitRoute cases.
  • Merges: the conflicts are in libs.versions.toml (rc14 plus master's breez-sdk-spark and paykit rc7) and the WalletViewModel constructor, which keeps both pubkySwapsRouteRepo and sparkRepo. The swap files are byte-identical to cd13b132.
  • The LOW: Spark arriving from master now reaches the hold payment (inline). A restore also matches lockups only against node payments (PubkySwapsRouteRepo.kt:123-124), so a Spark-paid swap left mid-flight refunds instead of being claimed. That loses nothing, so I'm not raising it separately.

The LOW can be fixed here or moved to a follow-up issue, your choice; please say which on the thread.

⚠️ Device gate: still not run — J1/J2 need the pubky-swap provider (coreyphillips/pubky-swap f45e204) against bitkit-docker's LND and the marketplace stack. CI: build, build-and-test, lint, detekt, compose-ui and five e2e shards pass; two 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 requested a review from jvsena42 November 11, 2026 15:16
@jvsena42

Copy link
Copy Markdown
Member

@ovitrif this conflicts with master again; please rebase. I'll review it again once it merges cleanly.

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

@jvsena42 merged master again in c866eb5 (a merge, so reviewed commits keep their shas). The only conflict was the bitkit-core line in gradle/libs.versions.toml, which stays on 0.8.0-rc14; master's biometric and Gradle wrapper bumps come in unchanged.

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/to/bitkit/services/CoreService.kt
@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

@jvsena42 merged master in 098c355, which now has its own rc14 bump and regtest auth (#1867). This PR takes master's Env.blocktankRegtestAuth in place of its auth = null, so the bitkit-core pin and CoreService now match master; app/build.gradle.kts keeps both the swap route fields and BLOCKTANK_REGTEST_AUTH.

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

Delta review of c22b8f6ed and the master merges c866eb5dc/098c35550: my LOW is fixed (confirmed on the thread), and nothing new.

098c35550 resolves its two conflicts correctly. build.gradle.kts keeps the swap BuildConfig fields (PUBKY_SWAPS_ROUTES, PUBKY_SWAPS_PROVIDERS) and master's BLOCKTANK_REGTEST_AUTH. The four regtest calls in CoreService.kt take master's auth = Env.blocktankRegtestAuth instead of the branch's auth = null. c866eb5dc only resolves libs.versions.toml.

⚠️ Device gate: still not run — J1/J2 need the pubky-swap provider (coreyphillips/pubky-swap f45e204) against bitkit-docker's LND and the marketplace stack.

Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

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.

feat: pay on-chain contacts from spending

5 participants