Skip to content

feat: pubky swaps as paykit route - #182

Draft
ovitrif wants to merge 33 commits into
feat/paykit-routerfrom
feat/pubky-swaps-route
Draft

ovitrif wants to merge 33 commits into
feat/paykit-routerfrom
feat/pubky-swaps-route

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #193
Refs:

Description

  • Adds a pubky_swaps module so that a Paykit payment routes through a Pubky Swaps provider when no balance the wallet holds reaches an endpoint the contact published. The user never picks the swap; the router does.
  • Extends the Paykit router of feat: pay contacts via spark #193 with swap/1 routes, following Paykit Capabilities §9.1. It runs the router's direct and service-leg check first. When no balance reaches the contact, it turns each trusted provider's offer into swap/1 edges, pre-ranks them by indicative fee (base + ppm + onchain, enforced minimum), asks the cheapest three for a firm quote and picks the smallest debit for the exact amount the contact must receive. It returns whichever costs less in fees, the cheapest live service leg or the swap; a swap wins a tie.
  • Adds the first route, a reverse swap: the wallet pays a hold invoice over Lightning, the provider locks bitcoin on chain, and bitkit-core claims it straight to the contact's on-chain address. The lock covers the wallet's own claim fee, so the contact receives exactly the amount asked for.
  • Derives each swap's claim key and preimage from the seed and a random index, and sends the index to the provider as the swap's owner_ref (Owners can now list their swaps with SwapListRequest, paged and scoped like status queries, and a swap-client restore helper rebuilds them from the listing for a wallet that has only its seed coreyphillips/pubky-swap#113). A restored wallet or a second device never repeats a published preimage.
  • Adds pubky_swaps_restore: for a wallet restored from its seed, it lists each provider's swaps (swap-list-v1), derives their keys from owner_ref, checks them against each contract, and rebuilds the records. Restored swaps claim to the wallet's own payout address, since the listing doesn't carry the contact's.
  • Keeps watching a broadcast claim on every pubky_swaps_advance until it confirms, sends it again if it drops out of the mempool, and fails a swap whose lockup the provider refunded or never made before the timeout.
  • Starts a claim only while the lockup's timeout is more than CLAIM_ABORT_MARGIN (6) blocks away, and keeps asking about a swap request the provider reports as pending until it accepts or refuses.
  • Checks everything the provider sends before the app pays: the quote against the request, the contract against the quote and the chain tip, the HTLC rebuilt from the wallet's own keys, and the hold invoice's hash, amount and expiry. These are pubky-swap's own swap-common validators.
  • Finds providers in the caller's candidate list, usually the user's contacts: each is asked for its live offer over iroh pubky-swap/direct/1 at once, with 20 s to answer, and those that run no provider are skipped. iroh needs no homeserver on either side.
  • Derives the wallet's provider identity, claim keys and preimages from the seed, under domain strings of their own. Swap records hold the contract and the key index, never a secret.
  • Raises the Android Bindgen job's timeout from 60 to 90 minutes: master already takes 50 to 55 minutes, and iroh pushes the four-ABI build past the limit.
  • New exports: pubky_swaps_route_paykit_payment, pubky_swaps_commit_route, pubky_swaps_advance, pubky_swaps_provider_edges.

The apps that use this are synonymdev/bitkit-android#1732 and synonymdev/bitkit-ios#1170, both on the prerelease 0.8.0-swaps.2 or its predecessor 0.8.0-swaps.1.

Cross-platform impact

  • New dependencies on Android and iOS: swap-common (git, coreyphillips/pubky-swap at 1801c80, unaudited), iroh 1.2.0 and electrum-client 0.25. The library size change against a release build is not measured yet. cargo check passes for aarch64-apple-ios.
  • The Swift package links Network.framework: iroh reads network interfaces through it, and apps that link BitkitCore without it fail with undefined nw_* symbols.
  • Changes within this PR's own exports:
    • PubkySwapRouteState gains ClaimBroadcast, and Claimed now means the claim is confirmed. Both apps' default/else branches keep compiling, but they wait for a block after the claim before showing success.
    • PubkySwapRouteQuote.quote_id is a route id the wallet assigns.
    • Swap keys derive under new domains from a random index, so swaps started by earlier builds of this PR can no longer be claimed.
    • New export: pubky_swaps_restore.
  • Swap routes are refused on mainnet until the claim fee can be bumped.
  • No existing export outside this PR changes.

What it needs to ship

  • Providers: mainnet Pubky Swaps providers with Lightning and on-chain liquidity, and a way to choose them. Today the app passes a configured list; Paykit Capabilities discovery (descriptors, follow graph, curated lists) is not built.
  • swap/1 in Paykit: Capability Descriptors published by providers, and paykit-cap.* negotiation over Encrypted Links. That 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). pubky-swap signs nothing at main, so quotes and contracts are not attributable yet.
  • Backup of swap state: swap records are not in the wallet backup, and there is no seed-only scan to rebuild them. An app restart between paying and claiming leaves the claim to a later pubky_swaps_advance call; the app does not resume it yet. Funds stay safe through the timeouts: the provider cancels its hold invoice, or refunds its own lock.
  • Claim fee bumping: the claim needs a change output back to the wallet so confirm_or_bump can raise its fee until it confirms (up to timeout - CLAIM_ABORT_MARGIN) while the contact's output stays exact; until then, swap routes are refused on mainnet.
  • Resume: the apps need to resume a swap on start and wake when the lockup reaches depth, instead of their 10-minute regtest deadline; the app killed after commit, after funding and before the claim, and a seed restore mid-swap, are first-class test cases.
  • Restored payments: a swap rebuilt after a restore claims to the wallet's own address, so the contact is not paid; the apps need to show it as returned to Savings.
  • One execution path: move the route onto the persistence, backup, recovery and claim handling of @coreyphillips's feat/pubky-swap-boltz once it builds without a local path.
  • Fees: the quote covers the provider's service fee, its on-chain cost and the wallet's claim fee. The wallet's claim fee rate is fixed at 2 sat/vB in the Android caller and should come from Bitkit's fee estimates. Lightning routing fees to the provider are on top.
  • Out of scope here: submarine routes (savings to a Lightning-only contact), USDT (Pubky Swaps is Bitcoin and Lightning only), auto-pay route selection, and cooperative signing.

Preview

The Android twin's journey: 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

  • A regtest Pubky Swaps provider: coreyphillips/pubky-swap at 8686702, built with 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)?,
    (swap-provider/src/lib.rs, in run).
  • Run it against synonymdev/bitkit-docker's bitcoind, electrs and LND: 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>, with PUBKY_SWAP_RECOVERY_PHRASE__FILE and PUBKY_SWAP_WALLET_MNEMONIC__FILE pointing at two throwaway BIP39 phrases. Fund the provider's first BIP84 address and mine six blocks. --doctor must pass.

Automated Checks

  • added keys.rs and router.rs unit tests: seed derivation, edge ranking, fee formula, endpoint preference
  • added client.rs live test (ignored by default): route, quote, commit, contract and hold-invoice checks against a running regtest provider. Run with PUBKY_SWAPS_PROVIDER=<pubky> PUBKY_SWAPS_ELECTRUM=tcp://127.0.0.1:60001 PUBKY_SWAPS_DESTINATION=<bcrt1 address> cargo test --lib pubky_swaps::client::live_tests -- --ignored
  • ran cargo fmt --check, cargo clippy --locked --lib --tests, cargo test --lib modules::pubky_swaps, cargo check --lib --target aarch64-apple-ios: pass
  • ran the live test against the provider above: pass
  • ran the full journey through the Android twin on a regtest emulator at 3fd9e9d, with the provider found only among the wallet's contacts: the provider logged hold invoice accepted, HTLC funded and client claimed; hold invoice settled, and the claim paid exactly 20,000 sats to the contact's address (one input from the HTLC, one output)

@ovitrif ovitrif self-assigned this Oct 29, 2026
@coreyphillips

Copy link
Copy Markdown
Collaborator

Reviewed from the pubky-swap side at 1abc3ff. The router, edges and swap-common validators line up with swap/1, and the commit path checks what it should before the app pays. Four things need fixing before mainnet, all in the reverse claim path, and then a proposal for converging with an older integration.

  1. Check the claim window before the first claim.

    • advance_blocking builds and broadcasts the claim as soon as the lockup has its confirmations (client.rs). It never checks against timeout_block_height.
    • A resume near the timeout therefore reveals the preimage in a race the wallet can't win: the provider can settle the hold invoice and still refund its lock.
    • pubky-swap's client refuses to start a claim within CLAIM_ABORT_MARGIN (6 blocks) of the timeout (reverse.rs; the reason is in timelock.rs).
    • swap_common::timelock::claim_start_is_safe is already in your dependency tree.
  2. Bump the claim fee until the claim confirms.

    • The fee is fixed at routing time from the caller's rate, which is 2 sat/vB on Android.
    • The record turns Claimed at broadcast, and nothing checks the claim or rebroadcasts it afterwards.
    • If the claim is evicted or stuck, the HTLC sits unclaimed after the provider has already settled. A provider that wanted to could then refund it after the timeout.
    • pubky-swap's client keeps bumping with swap_common::fee_bump::confirm_or_bump until timeout - CLAIM_ABORT_MARGIN (reverse.rs).
    • "The contact receives exactly the amount entered" leaves RBF no room, because the contact's output is the claim's only output. A change output back to the wallet can absorb the bumps and keep the delivered amount exact.
  3. The 10-minute claim deadline in the apps.

    • On mainnet the claim waits for 2 confirmations, which is swap-common's floor, MIN_MAINNET_CONFIRMATIONS.
    • So even when the provider funds within a minute, about 3 in 4 payments would show the payment error while the swap is still fine.
    • With no resume, the user's Lightning payment then stays held until the provider refunds its lock after the 144-block timeout and cancels the invoice. That is roughly a day, and at most 216 blocks.
    • This needs a resume path, and ideally a wake when the lockup reaches depth, before any deadline like this.
  4. Identity and pending.

One more, smaller: the edges use btc-{network}-* (router.rs), which PaymentEndpointIdentifier::new rejects. I've asked on pubky/paykit-rs#229 for swap/1 to list identifiers explicitly.

Converging with feat/pubky-swap-boltz.

  • My unmerged bitkit-core branch, feat/pubky-swap-boltz, runs Pubky swaps through the Boltz module via pubky-swap-boltz.
  • It already has much of what this PR lists under "What it needs to ship":
    • backup and restore of swap records (backup.rs);
    • recovery;
    • a claim window check;
    • swaps that pay an address (send.rs);
    • the user's own Pubky identity, including one imported through Ring.
  • I'd rather Bitkit ships one execution path: this PR's router and Paykit glue, on top of that branch's persistence, backup, recovery and claim handling.
  • The branch isn't a PR yet, and its Cargo.toml still points pubky-swap-boltz at a local path. So for now it's for reading, not building.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips thanks for the careful read.

  1. Check the claim window before the first claim.

Fixed in 160d443: advance_blocking now checks claim_start_is_safe(tip, timeout_block_height) before it builds the claim. Inside the margin, it reports Failed and leaves the swap to your refund, so the preimage never goes out in that race.

Separately, commit_route follows up on pending only once.

Also in 160d443: commit_route keeps querying status every 2 s while the provider answers pending, for up to 60 s. Nothing is paid before an acceptance, so a give-up costs nothing.

  1. Bump the claim fee until the claim confirms.

Agreed, and I'll take your change-output design: a change output back to the wallet absorbs the bumps, and the contact's output stays exact. The record then waits for the claim to confirm instead of turning Claimed at broadcast, and confirm_or_bump runs until timeout - CLAIM_ABORT_MARGIN. It's listed under What it needs to ship; this PR stays regtest-only until then.

  1. The 10-minute claim deadline in the apps.

Agreed. The 10 minutes is a regtest demo bound. Mainnet needs a resume on app start plus a background wake when the lockup reaches depth, with the error screen only for a real failure. Listed under What it needs to ship, together with the app-killed and seed-restore cases from pubky/paykit-rs#229.

  1. Identity and pending.

On identity: one seed-derived key keeps the user's Pubky unlinked from every provider. It also misses contact allowlists (coreyphillips/pubky-swap#88) and Encrypted Link recovery, as you say. That trade-off is better settled for swap/1 on pubky/paykit-rs#229 than here, so this PR keeps the separate key until it is.

the edges use btc-{network}-*, which PaymentEndpointIdentifier::new rejects

Right. The wildcard only labels the edge's family; the route itself always targets the contact's concrete identifier, for example btc-regtest-p2wpkh. I'll switch the edges to explicit identifiers once pubky/paykit-rs#229 lists them for swap/1.

I'd rather Bitkit ships one execution path

Agreed on one path: this PR's router and Paykit glue on top of feat/pubky-swap-boltz's persistence, backup, recovery and claim handling. Once your branch builds without the local path, I'll move the route onto it and drop the duplicate claim code here.

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two independent reviews.

needs changing before merge

  • Restored or reinstalled wallet reuses payment hashes whose preimages are public (src/modules/pubky_swaps/store.rs:56-66). After a seed restore, a reinstall, a wipe, or on a second device with the same seed, the next swap reuses the payment hash of the first swap, and that preimage is already public. store::reserve_index (src/modules/pubky_swaps/store.rs:59-61) returns 0 when <base>/pubky_swaps/next_index is missing. keys::swap_keys is a pure function of seed and index (keys.rs:50-66, and its own test asserts the preimage repeats for the same index). The first swap's claim put that preimage in its witness: swap-common build_htlc_spend pushes it at onchain.rs:287 at 8686702. Every Lightning hop that forwarded its hold-invoice payment also saw it at settlement. A provider (any contact that answers an offer) or a hop that knows the preimage can settle the new hold invoice without ever locking funds, and the wallet loses the debit. What I checked: the store default, the derivation, and the claim witness. Two things mitigate it in some cases. pubky-swap's own provider refuses a hash already in its store (ensure_reverse_hash_available, swap-provider/src/lib.rs:1828), so the same honest provider turns this into an invalid_request refusal; a different or dishonest provider has no such check. I did not verify whether the app's LDK node refuses to re-pay a hash it paid before; that depends on its payment store surviving the restore and is outside this repo. A random per-swap nonce mixed into the derivation and kept in the record would remove the reuse and still keep secrets out of the record. So would an index that cannot repeat, such as one seeded from the clock. The index file is also written without fsync, which gives a smaller version of the same reuse after a power loss.
  • Claim is broadcast once at a fixed fee and reported Claimed, with mainnet allowed (src/modules/pubky_swaps/client.rs:525-538). A claim that does not confirm before the timeout loses the whole debit, even to an honest provider, and the exports accept Network::Bitcoin. advance_blocking (client.rs:525-538) signs the claim with record.claim_fee_sat. That fee was fixed at route time from the app's fee_rate_sat_vb, which the description says is 2 sat/vB in the Android caller. It broadcasts once and stores claim_txid, and every later call returns Claimed at line 458 without looking at the chain again. The pinned provider settles the hold invoice as soon as it sees the claim in Electrum history, which includes the mempool (swap-provider/src/reverse.rs:570-600 and find_spend in swap-common chain/electrum.rs at 8686702). If that spend is not buried, it falls back to refunding. The claim signals RBF (ENABLE_RBF_NO_LOCKTIME), so once timeout_block_height passes, the provider's refund replaces a claim still sitting in the mempool. The invoice is then settled and the lock refunded. The description lists claim confirmation under what it needs to ship. Nothing in the code limits the exposure: route_paykit_payment and commit_route accept mainnet. Either land a re-sign/bump path that keeps watching until the claim is buried, or refuse Network::Bitcoin in these exports until it exists.
  • Seed restores reuse previously disclosed payment preimages (src/modules/pubky_swaps/store.rs:59-64). Restoring the seed without local swap state can reuse a preimage that an earlier claim already published. reserve_index restarts at zero, and swap_keys derives the preimage only from the seed and index. I confirmed identical preimages and payment hashes before and after removing next_index in an isolated test. A provider knowing the old preimage can settle the new hold invoice without funding its lockup.
  • Unconfirmed claims become permanently terminal (src/modules/pubky_swaps/client.rs:535-538). A broadcast claim receives no further protection even if it never confirms. Saving claim_txid makes every later advance return before checking the chain. I confirmed this with mocked chain responses: an unconfirmed broadcast remained Claimed when the chain became unavailable. If the claim stalls or is evicted, its public preimage lets the provider settle Lightning and later refund the lockup. The claim must remain monitored and bumpable while preserving the recipient's amount.

worth doing, does not block

  • Commit accepts a Taproot contract that advance can never find (src/modules/pubky_swaps/client.rs:389-396). commit_route asks for SwapScript::P2wsh but never checks that accept.script_type came back as P2wsh. swap-common's verify_contract says the caller must do that check (validate.rs:487-488). A Taproot acceptance with a consistent tree passes verification (client.rs:389-396), and the Taproot address is returned to the app as htlc_address. advance_blocking then always rebuilds a P2WSH script (client.rs:468-474), never finds the lockup, and reports AwaitingLockup until the provider refunds. Funds come back when the hold invoice is cancelled, but the wallet's Lightning payment stays locked until then. Reject any script_type other than P2wsh before validating.
  • advance has no terminal state for a missing or already spent lockup (src/modules/pubky_swaps/client.rs:478-538). If the provider never funds, advance_blocking returns AwaitingLockup { confirmations: 0 } forever, even after timeout_block_height has passed (client.rs:496-502). The app gets no signal to stop polling. If the app dies between the broadcast and store::save (client.rs:535-537), the next call rebuilds the same claim and rebroadcasts it. The broadcast fails with already-known or inputs-spent, so every call returns a Chain error and the record never reaches Claimed. The same happens if the record file is lost after a successful claim. The script history already fetched at line 481 contains any spend of the outpoint. Checking it before broadcasting, and returning Failed once the tip passes the timeout with no lockup, would close both.
  • Offers are not bound to the candidate that answered (src/modules/pubky_swaps/client.rs:222-235). The router keys offers and edges by the provider_pkarr written inside the offer (client.rs:229, router.rs:70), not by the candidate whose iroh connection returned it. quote_edge then connects to that claimed key (client.rs:142). So any contact can send the wallet's quote and commit traffic to an endpoint that was never in the candidate list. It can also name another candidate's key, which duplicates that provider's edge and spends the 3-quote fanout on it twice. The validators still apply, so this is not a theft path, but it undoes the restriction to the providers the caller passed. Drop offers where bare_pubky(&offer.provider_pkarr) != provider.
  • Probing every contact through n0 DNS discloses the contact list (src/modules/pubky_swaps/transport.rs:30). DirectClient::bind uses presets::N0 (transport.rs:30). In iroh 1.2.0 that preset resolves endpoint ids over HTTPS and DNS through n0's servers, and publishes the wallet's own address there (endpoint/presets.rs:116-138). route_paykit_payment probes every candidate at once (client.rs:207-221), and the description says the candidates are usually the user's contacts. Each payment that needs a swap therefore sends n0 a burst of lookups for the user's whole contact list, from one stable swap identity and IP. The fan-out is also unbounded, so a large contact list opens that many concurrent connections on a phone. The PR's claim that iroh needs no homeserver is true, but it does depend on n0's infrastructure. Consider capping the candidate count and documenting or replacing the n0 address lookup.
  • Swap store ignores a second init_db path (src/modules/pubky_swaps/store.rs:36-41). store::init saves the base path in a OnceCell and ignores later calls (store.rs:39). init_db replaces the activity, Blocktank and Boltz connections on every call. After a second init_db with a different base path, swap records and next_index keep going to the first directory while everything else moves.
  • Commit accepts contract types that advance cannot claim (src/modules/pubky_swaps/client.rs:388-396) [one reviewer only]. A provider can return TaprootBoltz despite the request selecting P2wsh, and commit still returns the invoice. I confirmed this with a valid Taproot acceptance and hold invoice in an isolated test. verify_contract validates the returned type, but advance_blocking only watches and spends P2WSH, so it cannot claim that lockup.
  • Expired unfunded swaps remain AwaitingLockup forever (src/modules/pubky_swaps/client.rs:496-508). An unfunded swap never becomes terminal after its timeout. The missing-lockup and insufficient-confirmation returns run before the timeout check. I reproduced an empty history at tip 2,000 with timeout 1,144, which still returned AwaitingLockup. Callers waiting for a terminal status can poll forever.
  • Sync permanently preserves an unknown fee rate (src/modules/activity/implementation.rs:1093). An outgoing activity first stored with an unknown rate (0) cannot acquire its rate through later syncs. I reproduced a preserving upsert of zero followed by a confirmed snapshot at 12 sat/vB: the absolute fee and confirmation updated, but the rate stayed zero. Preserve known positive rates while allowing an unknown rate to be filled.
  • The new dependency exceeds the declared Rust minimum (Cargo.toml:51). The pinned iroh 1.2.0 requires Rust 1.91, while this package still advertises Rust 1.90. Dependency validation rejects builds using the advertised minimum. Raise the package minimum or select a compatible dependency.

nits

  • Quote cache never evicts (src/modules/pubky_swaps/client.rs:279-286). QUOTES (client.rs:43) only drops an entry on a successful commit (line 428). Every routed payment the user abandons, and every expired quote, stays in memory for the life of the process. Pruning expired entries on insert would do.

the reviewers disagree, your call

  • Quote ID collisions change the committed recipient (src/modules/pubky_swaps/client.rs:279-285). Objection: Real, but non-blocking rather than blocking: Real: QUOTES is a process-global map keyed by the provider-chosen quote_id, so a second route with the same ID overwrites the first, and committing the first uses the second's address and debit. Not blocking: it needs two uncommitted routes in one process, the redirected recipient is one the wallet itself routed to, and the provider gains nothing.
  • Offers can redirect payments outside the provider list (src/modules/pubky_swaps/client.rs:227-229). Objection: Real, but non-blocking rather than blocking: Real: the queried provider is dropped and offers/edges are keyed by offer.provider_pkarr, which is never compared with the peer that answered. A listed candidate can therefore steer the wallet to an unlisted provider, or overwrite another provider's offer. Not blocking: the HTLC doesn't depend on trusting the provider, the counterparty's quote and contract are still checked, and the review screen shows its real pubky.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips all four blocking points are addressed in 7621dfc, with iOS bindings in 64cdede.

Restored or reinstalled wallet reuses payment hashes whose preimages are public

Each swap's claim key and preimage now derive from the seed and a random 32-byte nonce, under new v2 domains. The nonce lives in the record. next_index is gone, so a restore, a reinstall, a wipe or a second device on the same seed can't repeat a published preimage. Records are written with fsync on the file and its directory. Test: fresh_nonces_never_repeat_a_preimage.

Claim is broadcast once at a fixed fee and reported Claimed, with mainnet allowed

I took the option to refuse mainnet. pubky_swaps_route_paykit_payment answers None with a reason on Network::Bitcoin, and pubky_swaps_commit_route refuses it. Fee bumping with a change output stays under What it needs to ship; mainnet opens with it.

Unconfirmed claims become permanently terminal

advance now decides from the HTLC's script history and the tip on every call, in a pure claim::next_step with unit tests:

  • A broadcast claim reports the new ClaimBroadcast until it confirms. Claimed means confirmed.
  • A saved claim missing from the history is broadcast again, whatever the window, since its preimage is public.
  • The claim is saved before its first broadcast. A failed broadcast is logged rather than returned, so the app keeps polling.
  • A refund spend of the lockup is Failed, even after our claim went out.
  • A claim found on chain that the record missed is recorded.

Commit accepts a Taproot contract that advance can never find

Fixed: commit_route rejects any script_type other than P2WSH before validating.

advance has no terminal state for a missing or already spent lockup

Fixed. No lockup by the timeout is Failed. A spent lockup is read from the history before anything is broadcast. A crash between save and broadcast sends the same saved claim again.

Offers are not bound to the candidate that answered

Fixed: an offer whose provider_pkarr isn't the candidate that answered is dropped.

Probing every contact through n0 DNS discloses the contact list

Candidates are capped at 30, in the caller's order, so configured providers come first. The n0 lookups are documented on the cap. Replacing n0 discovery belongs with moving negotiation onto Encrypted Links (pubky/paykit-rs#229).

Swap store ignores a second init_db path

Fixed: the store's base path moves with every init_db.

Sync permanently preserves an unknown fee rate

Not in this PR's diff; it comes from #166 on master. Out of scope here, tracked in #186.

The new dependency exceeds the declared Rust minimum

Fixed: rust-version is now 1.91.

Quote cache never evicts

Fixed: expired quotes are pruned on every insert.

Quote ID collisions change the committed recipient

Fixed: the cache is keyed by an id the wallet assigns to each route. The provider's quote id stays inside the cached entry.

Offers can redirect payments outside the provider list

Fixed with the offer binding above.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed cdfdcae, following @coreyphillips's swap/1 notes on pubky/paykit-rs#229 (pubky/paykit-rs#229 (comment)):

@coreyphillips

Copy link
Copy Markdown
Collaborator

@ovitrif #105 is merged as coreyphillips/pubky-swap#113 (1801c80): SwapRequest.owner_ref, SwapListRequest/SwapList behind swap-list-v1, and created_at_unix, payment_hash_hex and owner_ref on every snapshot. The fields are as described above.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips thanks. Done as you described, in cab1972, with iOS bindings in 0f93ce0 and the pin moved to 1801c80 in f07bccb:

  • Swap keys derive from the seed and a random 32-byte index again. The swap request carries it as owner_ref = bitkit-swap-v4:<hex> (79 characters, valid under owner_ref_is_valid). The clock index and its wait are gone.
  • New pubky_swaps_restore lists each provider's swaps over swap-list-v1, page by page. For each swap it reads owner_ref, derives the keys, and checks payment_hash_hex and verify_contract before writing a record. Other wallets' swaps, foreign or missing owner_refs and swaps already on record are skipped.
  • The listing doesn't carry the contact's address, so a restored swap claims to a payout address the app passes, the wallet's own. A claim the provider already saw (claim_observed_txid_hex) is kept as the swap's claim.
  • cargo test --lib pubky_swaps (23 tests, including three restore cases) and clippy pass.

That closes both same-second points from your review.

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked f07bccb. The two same-second points are closed: a random 32-byte index, an owner_ref that round-trips, and no clock index left. Restore checks payment_hash_hex and verify_contract before it saves, and skips swaps already on record. cargo test --lib modules::pubky_swaps passes (23).

Two fixes before merge:

  1. A restored swap whose claim the provider saw can get stuck. restored_record saves the provider's claim_observed_txid_hex as a published claim with no claim_tx_hex (client.rs#L765-L781). If that claim then leaves the mempool, next_step returns Rebroadcast (claim.rs#L108-L109) and advance_blocking has nothing to send (client.rs#L563-L567). The wallet never claims, while the preimage is already public and the provider can refund at the timeout. Suggest: with no saved claim hex, build and broadcast a fresh claim (no window check, the preimage is out), plus a test.
  2. list_swaps has no page limit (client.rs#L706-L727). A provider that keeps returning next_cursor stops pubky_swaps_restore from returning, and the providers after it are never asked. Suggest a page cap, or stopping on a cursor seen before.

Minor: store::save(&record)? (client.rs#L697) ends the whole restore on one write error.

Pin: keep 1801c80 in this PR. Bump it with the owner-side wake work, which needs SwapWakeCapability from d5f1ce5. swap-common only gains ed25519-dalek 2 and chacha20poly1305 0.10 by then.

@ovitrif

ovitrif commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips all three are fixed in 3d36a27.

  1. A restored swap whose claim the provider saw can get stuck.

next_step now returns Rebroadcast with the lockup outpoint. When the record holds no claim hex, as for a restored swap that only knows the provider's observed txid, advance builds a claim of its own and broadcasts it whatever the window, since the preimage is public. The new claim goes through the same build, save and broadcast path as a first claim. Test: claims_again_when_a_claim_the_provider_saw_left_the_mempool.

  1. list_swaps has no page limit

It now stops on a cursor it already saw, or after 100 pages, logs which, and keeps what it read, so the providers after it are still asked.

Minor: store::save(&record)? ends the whole restore on one write error.

A failed save now logs and skips that swap; the rest of the restore goes on.

Pin: keep 1801c80 in this PR.

Kept. It moves with the owner-side wake work.

cargo test --lib modules::pubky_swaps passes (24). The same fixes are merged into #184 at 8aac920.

@ovitrif
ovitrif changed the base branch from master to feat/paykit-router November 6, 2026 18:23
@ovitrif

ovitrif commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

Retargeted onto #193, which now holds the Paykit router and its Spark legs, so that paying contacts via Spark can ship without waiting for pubky/paykit-rs#229.

This PR now carries only Pubky Swaps:

  • pubky_swaps uses the router's types (PaykitEndpoint, PaykitRouteRequest, PaykitRoute, ServiceRouteLeg).
  • pubky_swaps_route_paykit_payment runs the same direct and service-leg check first, then compares the cheapest live leg against the swap quote.
  • PaykitRouteRequest gains providers and fee_rate_sat_vb, and PaykitRoute gains Swap.

swap/1 as a second leg kind waits for pubky/paykit-rs#229 to settle.

# Conflicts:
#	bindings/ios/bitkitcore.swift
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	Package.swift
#	bindings/android/gradle.properties
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	Package.swift
#	bindings/android/gradle.properties
…bky-swaps-route

# Conflicts:
#	bindings/ios/bitkitcore.swift
#	src/modules/paykit_route/router.rs
#	src/modules/paykit_route/types.rs
…bky-swaps-route

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	Package.swift
#	bindings/android/gradle.properties
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