Skip to content

feat: pay contacts through pubky swaps - #1732

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

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

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

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 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.
  • 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.
  • Shows Bitcoin Sent only after the claim is broadcast; a claim that does not happen in 10 minutes ends in the payment error screen.
  • Behind PUBKY_SWAPS_ROUTES=true on regtest debug builds only.

Builds against the bitkit-core prerelease 0.8.0-swaps.1, cut from synonymdev/bitkit-core#182; 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 (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: 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.
  • iOS: the same flow on synonymdev/bitkit-ios.

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.
  • AppViewModel resume of 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-android2x.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 8686702, cargo build --release -p swap-provider --features full, with one local change so it answers over iroh without signing in to a homeserver:
    -        "file" => Transport::from_recovery_file(&identity.value, &identity.passphrase).await?,
    -        _ => Transport::from_recovery_phrase(&identity.value, Some(&identity.passphrase)).await?,
    +        "file" => Transport::unsigned_from_recovery("file", &identity.value, &identity.passphrase)?,
    +        _ => Transport::unsigned_from_recovery("phrase", &identity.value, &identity.passphrase)?,
    Run it with PUBKY_SWAP_RECOVERY_PHRASE__FILE and PUBKY_SWAP_WALLET_MNEMONIC__FILE pointing at two throwaway BIP39 phrases: swap-provider --network regtest --directions submarine,reverse --min-amount 10000 --max-amount 1000000 --base-fee 100 --fee-ppm 2500 --confirmations 1 --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>. Send 5 BTC to its first BIP84 address (m/84'/1'/0'/0/0) and mine six blocks; --doctor must pass. It prints its pubky on start.
  • App: build bitkit-core feat: pubky swaps as paykit routes bitkit-core#182 for Android, then 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 -PbitkitCoreAar=<aar>. 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

Manual Tests

N/A

Automated Checks

  • updated AppViewModelSendFlowTest.kt — constructs the view model with the new route repository
  • ran detekt, testDevDebugUnitTest and lintDevDebug against the local bitkit-core AAR: pass

Evidence

  • J1 at 46ad2a9 (the head since changes only the journey file) on a regtest emulator (Android 15), with feat: pubky swaps as paykit routes 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

  • 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.
  • iOS not started.

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

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.

1 participant