Skip to content

feat: pay contacts through pubky swaps - #1170

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

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

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Twin: synonymdev/bitkit-android#1732
Dependency: synonymdev/bitkit-core#182
Refs:

This PR routes a Paykit contact payment through a Pubky Swaps provider when the contact only accepts on-chain bitcoin and the wallet holds only a spending balance.

Description

  • Routes a Paykit contact payment through a Pubky Swaps provider when the contact's only endpoints are on-chain and savings cannot cover it, so a wallet holding only spending can still pay that contact. 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.
  • Offers only Spending as the source for such a contact, quotes the route on Continue, and shows it 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.
  • Shows Bitcoin Sent only after the claim is broadcast; a refused commit, a failed payment or a claim that does not happen in 10 minutes ends in the payment error screen.
  • Behind PUBKY_SWAPS_ROUTES (environment or launch argument) on regtest debug builds only.
  • Ports the paykit-swap-route.xml journey from Android.

Builds against the bitkit-core prerelease 0.8.0-swaps.2, cut from synonymdev/bitkit-core#182, which also links Network.framework for the swap transport; move to a regular release once that merges.

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

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: negotiation over Paykit Encrypted Links (paykit-cap.*) instead of iroh, which 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), and signed quotes and contracts from pubky-swap.
  • Backup of swap state: swap records are not in the wallet backup and cannot be rebuilt from the seed alone. 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).
  • 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.

Out of Scope

  • Incoming Paykit payment requests: never swap-routed here, because their payment proofs need the swap's claim txid wired into the proof flow.
  • Submarine routes: savings paying a Lightning-only contact.
  • USDT routes and allowance auto-pay.
  • Resuming an unfinished swap after a restart.

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, and the contact receives exactly 20,000 sats on chain (2x speed, regtest).

swaps-route-ios2x.mp4

QA Notes

Setup

  • Chain, provider and contact: as in feat: pay contacts through pubky swaps bitkit-android#1732 (bitkit-docker main with the marketplace, payment-requests and lnurl-pay profiles; coreyphillips/pubky-swap at 8686702 with its one local change; bitkit-docker's fixture-issuer as the contact).
  • App: xcodebuild build -project Bitkit.xcodeproj -scheme Bitkit -configuration Debug -destination 'platform=iOS Simulator,name=<simulator>' 'SWIFT_ACTIVE_COMPILATION_CONDITIONS=$(inherited) E2E_BUILD' E2E_BACKEND=local E2E_NETWORK=regtest E2E_HOMESERVER_PUBKY=$(./pubky-marketplace info | jq -r .homeserver_z32), installed on a simulator on the same Mac as bitkit-docker and launched with xcrun simctl launch <udid> to.bitkit -PUBKY_SWAPS_ROUTES=true. The simulator reaches the host loopback directly.
  • 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, opened with xcrun simctl openurl <udid> lightning:<lnurl>); leave savings empty; create a Pubky profile; save the provider's and the fixture-issuer's pubkys as contacts (bitkit://contact?pubky=<pubky>).
  • If ./pubky-marketplace restarts the testnet container, restart homeserver-proxy, paykit-server, fixture-issuer and rc56-peer too: they share its network namespace, and profile creation fails with "complete Pubky auth flow" otherwise.

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

Manual Tests

N/A

Automated Checks

N/A

Evidence

  • J1 on a regtest iPhone simulator (iOS 26.5), with bitkit-core 0.8.0-swaps.2 and no PUBKY_SWAPS_PROVIDERS, so the provider came only from the wallet's contacts (the recording above). The build was 7315045 before its formatting-only change to the new service file:
    • the amount screen offered only Spending; the confirm screen showed Route: Pubky Swaps · 5no8...pquo and a fee of ₿ 986 for 20,000 sats;
    • the provider logged 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;
    • Bitcoin Sent showed ₿ 20,000, and the activity list shows the Lightning payment of ₿ 20,986.
  • A second take of J1 paid the same way.
  • An earlier run whose swipe came over two minutes after the quote ended in the payment error screen: the provider refused the commit with quote_expired, and nothing was paid.
  • The unit and UI test selection of unit-tests.yml passed at 7315045; SwiftFormat lint is clean on the changed files apart from findings already on master, and validate-translations.js passes.

Gaps

  • The provider runs with a local patch (Setup), and the provider's contact entry has no Pubky profile, so the route shows its key rather than a name.
  • A quote that expires on the confirm screen needs a trip back to the amount screen for a new one; the error screen's Retry was not tried.
  • Unlike Android, Bitcoin Sent opens the Lightning payment's activity, since the claim transaction is not the wallet's own.

Models used

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

A spending-only wallet pays a Paykit contact whose only endpoint is on-chain through a Pubky Swaps provider found among its contacts. Behind PUBKY_SWAPS_ROUTES on regtest debug builds. Uses bitkit-core 0.8.0-swaps.2.
@ovitrif ovitrif self-assigned this Oct 30, 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