Skip to content

feat: pay contacts via spark - #193

Merged
ovitrif merged 13 commits into
masterfrom
feat/paykit-router
Nov 9, 2026
Merged

ovitrif merged 13 commits into
masterfrom
feat/paykit-router

Conversation

@ovitrif

@ovitrif ovitrif commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Refs:

Description

  • Adds paykit_route_payment, which picks how to pay a Paykit contact, so a Spark-only wallet can pay contacts without Pubky Swaps. The function is synchronous, needs no seed and makes no network calls.
  • Direct: when a balance the wallet holds reaches an endpoint the contact published, the wallet pays it directly.
    • Lightning comes first: btc-lightning-bolt11, then btc-lightning-lnurl, when lightning_spendable_sat covers the amount. The Spark balance counts as Lightning-spendable.
    • A BOLT11 invoice counts only when it is for the wallet's network, unexpired and amountless or for exactly the amount.
    • Then on chain: btc-{network}-p2tr, p2wpkh, p2sh or p2pkh, in that order, when onchain_spendable_sat covers it.
  • Service: when no balance reaches the contact, the router returns the cheapest of service_legs, such as a Spark withdrawal to the contact's on-chain address.
    • A leg must deliver exactly the amount, to an endpoint the contact published, on a quote that has not expired.
    • The app quotes each leg with its Spark engine and runs the one returned; the router never calls Spark.
  • None: when nothing reaches the contact, it returns a reason the app can show the user.
  • Endpoint identifiers follow the Payment Endpoint Identifier spec of pubky/paykit-rs#253 and match what both apps publish. A contact's endpoint for another network is never paid. The spec has no Testnet4 rail and BOLT11 has no Testnet4 currency, so a Testnet4 wallet routes nothing.
  • The apps pass the contact's full endpoint list. PaykitRoute::None's reason is English detail for logs; the apps show their own localized text.
  • Adds a Paykit Route Tests CI job that runs the module's tests.

Out of Scope

  • src/modules/pubky_swaps: swap routes stay in draft feat: pubky swaps as paykit route #182, which stacks on this PR and adds swap/1 as a second leg kind once pubky/paykit-rs#229 settles.
  • btc-lightning-bolt12: not routed. Breez SDK 0.28.0 parses BOLT12 offers, but its prepare_send_payment refuses them as an unsupported payment method, so a Spark wallet can't pay one yet.
  • Lightning endpoints when the wallet lacks the Lightning balance: there is no on-chain-to-Lightning leg.
  • Two-leg routes.

Design

N/A — no UI changes.

Preview

N/A — no user-visible changes.

QA Notes

Journeys

N/A

Manual Tests

N/A

Automated Checks

  • added src/modules/paykit_route/router.rs tests: Lightning first, then the preferred on-chain type; direct before any leg; cheapest live leg to a published endpoint; BOLT11 invoices for another network, expired or for another amount skipped; legs to an unpublished address ignored; each network rail, and nothing at all for Testnet4; the reason when nothing routes
  • added .github/workflows/paykit-route-tests.yml

What it needs to ship

@ovitrif ovitrif self-assigned this Nov 6, 2026
@ovitrif
ovitrif marked this pull request as ready for review November 6, 2026 21:35
@ovitrif
ovitrif requested review from a team, coreyphillips and pwltr and removed request for a team November 6, 2026 21:35
@ovitrif

ovitrif commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

Ready for review: CI passes on d452bf6, which merges master. The prerelease 0.8.0-spark.2 builds from this PR's code, and the apps move onto it in synonymdev/bitkit-android#1754 and synonymdev/bitkit-ios#1191.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T21:53:52.729501Z d452bf6 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@talosmachina talosmachina left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No findings. Adds paykit_route_payment, a pure router that pays a Paykit contact directly (Lightning, then on chain for the wallet's network), else through the cheapest live quoted service leg, else returns None with a reason. Reviewed d452bf6, full tier, static: the PR's code was not built or run here.

What I checked, and 5 candidates I ruled out

Read in full: src/modules/paykit_route/{router,types,errors,mod}.rs, README.md, the src/lib.rs export, the version files (Cargo.toml, Package.swift, gradle.properties), the bitkitcoreFFI.h additions
Traced: the consumer in synonymdev/bitkit-android#1754 (PaykitRouteRepo.route, quotePaykitRoute, withdrawalRouteLeg), which builds ServiceRouteLeg with debitSat = amount + userFeeSat and validUntilUnix = quote.expiresAt, matching the router's contract
CI: green (Android and iOS bindgen, manifests, SwiftPM, usdt-tests, wake-tests)

Ruled out

  • Fee underflow in min_by_key(debit - delivered): the filter before it keeps only debit_sat >= delivered_sat.
  • Expiry off by one: valid_until_unix > now_unix drops a quote at its expiry second; the test's NOW leg covers that.
  • Paying a contact's endpoint on another network: direct on-chain and service legs both build btc-{rail}-{kind} from the wallet's network; Lightning ids carry no network segment, so they are taken as published.
  • Version files out of sync: Cargo.toml, Package.swift and gradle.properties all read 0.8.0-spark.2, and the v0.8.0-spark.2 tag exists at e72a49f; the merge from master after it only brings in the trezor-connect-rs 10.0.1 bump.
  • English reason in PaykitRoute::None: the crate already returns English reasons this way (UrError::InvalidUr, UsdtError::TransactionRejected), so this follows the house pattern.

Merge confidence: 4/5, no CI job runs cargo test modules::paykit_route (only the usdt and wake modules are tested in CI), and I could not run the new router tests 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.

worth doing, does not block

  • Direct Lightning routing never looks at the endpoint value (src/modules/paykit_route/router.rs:33-35)
  • Tests miss the published-address check and every real network label (src/modules/paykit_route/router.rs:118-123)
  • No CI job runs the paykit_route tests (.github/workflows/wake-tests.yml:31). The router's tests are never run by CI, so the Automated Checks listed in the description only run when someone runs them locally. The only Rust test workflows are usdt-tests.yml (modules::usdt) and wake-tests.yml (modules::wake). The PR's checks confirm this: the two bindgen jobs, the manifest jobs, usdt-tests and wake-tests. There is no general cargo test job. Master's wake work added its own workflow, so a matching cargo test --locked --lib modules::paykit_route job would follow that precedent for a module that selects payment routes.
  • Testnet4 is routed to Testnet3 endpoints (src/modules/paykit_route/router.rs:15-20)

the reviewers disagree, your call

  • PaykitRoute::None reason is one fixed English sentence (src/modules/paykit_route/router.rs:105-107)
  • App PRs bypass the Direct path, so the planned bolt11 run won't exercise it (src/lib.rs:3745-3757)

Comment thread src/modules/paykit_route/router.rs Resolved
Comment thread src/modules/paykit_route/router.rs Resolved
Comment thread src/modules/paykit_route/router.rs Resolved
Comment thread src/lib.rs Resolved
Comment thread src/modules/paykit_route/router.rs Resolved
Decode BOLT11 endpoint values and skip invoices for another network, expired at now or for another amount. Route nothing on chain for Testnet4, which has no rail in the spec. Test the published-address check and each network rail.
@ovitrif

ovitrif commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

@coreyphillips re-requesting your review: master's rules need an approval after the last push, and your approval was at d452bf6. Since then:

  • 5b75abf routes only Lightning invoices the wallet can pay (network, expiry, amount) and on-chain endpoints of the wallet's network.
  • b2443d8 runs the paykit_route tests in CI.
  • ac70416 bumps to the 0.8.0-spark.3 prerelease.

@ovitrif
ovitrif requested a review from coreyphillips November 9, 2026 08:26

@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 after 3 new commits (5b75abf, b2443d8, ac70416). Nothing blocking remains.

addressed

  • Tests miss the published-address check and every real network label (src/modules/paykit_route/router.rs:118-123). Fixed: In a scratch copy I reran the three earlier mutants: legs matched by method_id only, mainnet labelled mainnet, Signet mapped to testnet. Each now fails one test: ignores_legs_to_an_address_the_contact_did_not_publish (router.rs:321) or pays_on_chain_endpoints_of_each_network_rail (router.rs:331). The unmutated module passes all 8 tests.
  • No CI job runs the paykit_route tests (.github/workflows/wake-tests.yml:31). Fixed: .github/workflows/paykit-route-tests.yml runs cargo test --locked --lib modules::paykit_route, the same way wake-tests runs its module. The PR's paykit-route-tests check log at ac70416 shows running 8 tests and 8 passed.
  • PaykitRoute::None reason is one fixed English sentence (src/modules/paykit_route/router.rs:105-107). Fixed: types.rs:54-55 and README.md:24-25 now document reason as English log detail. Android at 47a1484a8 logs it and toasts its own localized wallet__send_paykit_no_route__* or wallet__send_amount_exceeded__* strings instead. I read the Android code but did not run it.
  • App PRs bypass the Direct path, so the planned bolt11 run won't exercise it (src/lib.rs:3745-3757). Fixed: types.rs:16-17 now tells callers to pass the full list. Android 47a1484a8 routePaykitPayment passes route.endpoints, every non-USDT endpoint, from Spending and pays Direct through payDirectEndpoint. iOS 771b250ff PaykitRouteService.route sends the request through paykitRoutePayment. I read both apps but did not run them.
  • Testnet4 is routed to Testnet3 endpoints (src/modules/paykit_route/router.rs:15-20). Fixed: network_label (router.rs:19-27) now returns None for Testnet4, so onchain_method_ids is empty. router.rs:344-349 asserts None for a Testnet4 wallet with a fundable btc-testnet-p2tr and a matching leg. Mapping Testnet4 back to testnet fails that test in my scratch run.

still open

  • Direct Lightning routing never looks at the endpoint value (src/modules/paykit_route/router.rs:33-35). lightning_endpoint matches on method_id only, so an expired, fixed-amount or wrong-network invoice is returned as Direct, which contradicts the claim that a contact's endpoint for another network is never paid. In a scratch copy of the router, a regtest wallet with btc-lightning-bolt11 = lnbc1mainnet and a fundable btc-regtest-p2wpkh got Direct on the mainnet invoice. An empty value also returned Direct. A Testnet4 wallet paid btc-testnet-p2wpkh, because network_label maps every other network to testnet through its _ arm. This fails as a failed payment rather than lost funds, and a live on-chain route is not tried. Both apps already filter with isPayable / isPayableEndpoint (expiry and network mismatch), so the guarantee holds only if callers pre-filter. Nothing states that: contact_endpoints is documented as the endpoints 'as Paykit resolved them'. Either document that callers must pass only payable endpoints, and narrow the README and description claim to on-chain endpoints, or decode bolt11 values in the router using now_unix and network. Spelling out Testnet4 in network_label would also close the wildcard. Codex still sees it: The network check at src/modules/paykit_route/router.rs:65 still needs a Testnet4 guard. A scratch harness using the unchanged module reproduced a debug panic and release-mode Direct selection of a regtest BOLT11 for a Testnet4 wallet.

new since the last review

worth doing, does not block

  • Testnet4 with a BOLT11 endpoint panics in debug, pays regtest invoices in release (src/modules/paykit_route/router.rs:65)

Comment thread src/modules/paykit_route/router.rs Resolved
@ovitrif
ovitrif requested a review from coreyphillips November 9, 2026 08:45
@ovitrif

ovitrif commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

@coreyphillips thanks. The "still open" item reads the router from before 5b75abf. lightning_payable decodes the BOLT11 value and pays it only when the invoice's currency matches the wallet's network, it doesn't expire at now_unix, and it has no amount or exactly the amount asked; an empty value is never Direct. skips_bolt11_invoices_it_cannot_pay_now covers each case, the empty value as an LNURL endpoint. Since 5b75abf network_label maps only Bitcoin, Testnet, Signet and Regtest to a rail and Testnet4 to None, and 7f779a7 also gives the invoice currency an explicit match, so Testnet4 routes nothing.

@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 after 1 new commit (7f779a7). Nothing blocking remains.

addressed

  • Direct Lightning routing never looks at the endpoint value (src/modules/paykit_route/router.rs:33-35). Fixed: lightning_payable (src/modules/paykit_route/router.rs:53-73) now decodes BOLT11 values and checks currency, expiry and exact amount. An empty value is never Direct, and network_label (router.rs:19-27) gives Testnet4 no rail. The contact_endpoints doc (types.rs:16-17) now says to pass the full list. I ran skips_bolt11_invoices_it_cannot_pay_now and the rest of the module's tests at HEAD in a scratch copy, and all 8 pass.
  • Testnet4 with a BOLT11 endpoint panics in debug, pays regtest invoices in release (src/modules/paykit_route/router.rs:65). Fixed: invoice_currency (router.rs:78-86) maps the network explicitly and returns None for Testnet4, and Currency::from is no longer called. The Testnet4 test (router.rs:360-372) now publishes regtest and testnet BOLT11 endpoints with Lightning balance and expects None. With the fix reverted in a scratch copy, that test panics at lightning-invoice-0.32.0/src/lib.rs:385. With the fix in place it passes.

new since the last review

worth doing, does not block

  • Testnet4 still routes LNURL endpoints, despite "routes no Lightning" (src/modules/paykit_route/router.rs:59-77)

Comment thread src/modules/paykit_route/router.rs Resolved
@ovitrif
ovitrif requested a review from coreyphillips November 9, 2026 09:06

@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 after 1 new commit (3b0ed79). Nothing blocking remains.

addressed

  • Testnet4 still routes LNURL endpoints, despite "routes no Lightning" (src/modules/paykit_route/router.rs:59-77). Fixed: lightning_payable (src/modules/paykit_route/router.rs:60-65) now returns false when invoice_currency is None, before the LNURL branch, so Testnet4 pays neither method. The Testnet4 case in pays_on_chain_endpoints_of_each_network_rail (router.rs:363-375) now publishes btc-lightning-lnurl with 20_000 sat Lightning-spendable and asserts None. Under the old ordering that LNURL would have routed Direct, so the test guards the fix. I ran cargo test --lib modules::paykit_route at HEAD and all 8 tests passed.

new since the last review

nits

  • README still says Testnet4 only loses on-chain routing (src/modules/paykit_route/README.md:29)

Comment thread src/modules/paykit_route/README.md Resolved
@ovitrif
ovitrif requested a review from coreyphillips November 9, 2026 09:14

@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 after 1 new commit (b563bab). Nothing blocking remains.

addressed

  • README still says Testnet4 only loses on-chain routing (src/modules/paykit_route/README.md:29). Fixed: README.md:29-30 now says a Testnet4 wallet routes nothing: no Lightning endpoint, on-chain endpoint or service leg. That paragraph limits Rule 1's LNURL wording at line 16. I checked it against router.rs. At :60-62, lightning_payable returns false before the LNURL value check when invoice_currency is None. At :30-38, onchain_method_ids is empty for Testnet4, which also empties the leg filter at :121. At lib.rs, the Network::Testnet4 input becomes bitcoin::Network::Testnet4 through onchain/types.rs:128, and the test at router.rs:361-375 covers that case.

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Merging: approved at b563bab, all checks pass and no review thread is open.

@ovitrif
ovitrif merged commit 50065eb into master Nov 9, 2026
7 checks passed
@ovitrif
ovitrif deleted the feat/paykit-router branch November 9, 2026 10:13
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.

3 participants