From e37afcf15488835ba1e35ccc8046697da6aff45e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 19:15:02 +0100 Subject: [PATCH 01/12] feat: route paykit payments directly or through a spark leg --- AGENTS.md | 4 +- CHANGELOG.md | 1 + src/lib.rs | 17 +++ src/modules/mod.rs | 1 + src/modules/paykit_route/README.md | 25 ++++ src/modules/paykit_route/errors.rs | 8 ++ src/modules/paykit_route/mod.rs | 9 ++ src/modules/paykit_route/router.rs | 221 +++++++++++++++++++++++++++++ src/modules/paykit_route/types.rs | 53 +++++++ 9 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 src/modules/paykit_route/README.md create mode 100644 src/modules/paykit_route/errors.rs create mode 100644 src/modules/paykit_route/mod.rs create mode 100644 src/modules/paykit_route/router.rs create mode 100644 src/modules/paykit_route/types.rs diff --git a/AGENTS.md b/AGENTS.md index 743a64e6..a69ac772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Android bindings are built and published by `.github/workflows/gradle-publish.ym ```bash cargo test # All tests -cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt) +cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, paykit_route, usdt) ``` ## Lint & Format @@ -35,7 +35,7 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), ## Architecture - `src/lib.rs` — UniFFI exports and module re-exports -- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt +- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, paykit_route, usdt - `bindings/` — Platform-specific binding outputs (ios/, android/, python/) - `build.sh`, `build_ios.sh`, `build_android.sh`, `build_python.sh` — Build scripts diff --git a/CHANGELOG.md b/CHANGELOG.md index c76518ce..9ea53cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `paykit_route_payment`, which picks how to pay a Paykit contact: directly when a held balance reaches one of the contact's endpoints, otherwise through the cheapest quoted service leg, such as a Spark withdrawal to the contact's on-chain address. - `onchain_compose_transaction` keeps the synced watch-only wallet per extended key, script type, network and Electrum URL, so later composes for the same account run an incremental sync that reuses stored transactions instead of a full sync. Every compose still syncs before building PSBTs. Composes with and without a fingerprint share one wallet. Against the staging regtest Electrum with the Trezor emulator account, a compose drops from about 7.5 s to about 3.4 s after the first one. - Add a `wake` module for the wake push gateway: device key, secret and install id generation, a signed registration (`wake_prepare_registration`, `wake_sign_pubky_proof`, `wake_register`), decryption of legacy (v0) and v1 envelopes from any delivered push (`wake_decrypt_push`), and the device routes for acks, presence, topics and unregistering. Grants let a peer wake the device (`wake_create_grant`, `wake_list_grants`, `wake_revoke_grant`), and `wake_send_peer_wake` sends a peer wake with a grant token another device handed over. The v1 wire format is experimental until the apps adopt it. `register_device` and `test_notification` are unchanged. diff --git a/src/lib.rs b/src/lib.rs index 6f22cfaf..f2836d9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,6 +93,9 @@ pub use modules::activity; pub use modules::boltz as boltz_swaps; pub use modules::lnurl; pub use modules::onchain; +pub use modules::paykit_route::{ + PaykitEndpoint, PaykitRoute, PaykitRouteError, PaykitRouteRequest, ServiceRouteLeg, +}; pub use modules::scanner::{DecodingError, LnurlPayData, Scanner}; pub use modules::seedqr::{decode_compact_seed_qr, decode_standard_seed_qr, SeedQrError}; pub use modules::usdt::{ @@ -3739,6 +3742,20 @@ pub async fn boltz_stop_swap_updates() { .await; } +/// Pick how to pay a Paykit contact: `Direct` when a balance the wallet holds reaches one of the +/// contact's endpoints, otherwise the cheapest of the request's service legs, or `None` with a +/// reason. The user never picks the route. +#[uniffi::export] +pub fn paykit_route_payment( + request: PaykitRouteRequest, + network: Network, +) -> Result { + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + modules::paykit_route::route_payment(&request, network.into(), now_unix) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 65386200..f7533a66 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -5,6 +5,7 @@ pub mod hardware_wallet; pub mod jade; pub mod lnurl; pub mod onchain; +pub mod paykit_route; pub mod pubky; pub mod scanner; pub mod seedqr; diff --git a/src/modules/paykit_route/README.md b/src/modules/paykit_route/README.md new file mode 100644 index 00000000..e3fd7433 --- /dev/null +++ b/src/modules/paykit_route/README.md @@ -0,0 +1,25 @@ +# Paykit Route Module + +Picks how the wallet pays a Paykit contact, from the contact's published Payment Endpoints and what +the wallet can spend. The user never picks the route: `paykit_route_payment` does, and the review +screen shows it. + +## Rules + +1. **Direct.** When a balance the wallet holds reaches an endpoint the contact published, the + wallet pays it directly: over Lightning first (`btc-lightning-bolt11`, then + `btc-lightning-lnurl`) when `lightning_spendable_sat` covers the amount, else on chain + (`btc-{network}-p2tr`, `p2wpkh`, `p2sh`, `p2pkh`, in that order) when `onchain_spendable_sat` + does. Spark's balance counts as Lightning-spendable, so paying a Lightning endpoint from Spark + needs no leg. +2. **Service leg.** Otherwise the router keeps the cheapest of `service_legs` that delivers exactly + the amount to an endpoint the contact published and whose quote has not expired. A leg is a + one-step route through a service the wallet holds a balance on, such as a Spark withdrawal to + the contact's on-chain address. The app quotes each leg with that service's engine and runs the + one the router returns; the router never talks to the service. `atomicity` states who the leg + trusts (`trusted` for Spark's operators). +3. **None.** Nothing reaches the contact; `reason` says why, for the user. + +The rail segment of an on-chain Payment Endpoint Identifier names the network the apps publish for +(`bitcoin`, `testnet`, `signet`, `regtest`), so a contact's endpoint for another network is never +paid. diff --git a/src/modules/paykit_route/errors.rs b/src/modules/paykit_route/errors.rs new file mode 100644 index 00000000..a0694ba4 --- /dev/null +++ b/src/modules/paykit_route/errors.rs @@ -0,0 +1,8 @@ +use thiserror::Error; + +#[derive(uniffi::Error, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum PaykitRouteError { + #[error("Invalid route request: {message}")] + InvalidRequest { message: String }, +} diff --git a/src/modules/paykit_route/mod.rs b/src/modules/paykit_route/mod.rs new file mode 100644 index 00000000..a90c3a75 --- /dev/null +++ b/src/modules/paykit_route/mod.rs @@ -0,0 +1,9 @@ +//! Wallet-local route selection for paying a Paykit contact. + +mod errors; +mod router; +mod types; + +pub use errors::PaykitRouteError; +pub use router::route_payment; +pub use types::{PaykitEndpoint, PaykitRoute, PaykitRouteRequest, ServiceRouteLeg}; diff --git a/src/modules/paykit_route/router.rs b/src/modules/paykit_route/router.rs new file mode 100644 index 00000000..8f35e362 --- /dev/null +++ b/src/modules/paykit_route/router.rs @@ -0,0 +1,221 @@ +//! Picks how to pay a Paykit contact. A contact the wallet can pay from its own balance is paid +//! directly, over Lightning first. Otherwise the cheapest service leg that delivers the exact +//! amount to an endpoint the contact published is used. The user never picks the route. + +use super::errors::PaykitRouteError; +use super::types::{PaykitEndpoint, PaykitRoute, PaykitRouteRequest, ServiceRouteLeg}; + +/// Lightning Payment Endpoint Identifiers in the order Bitkit prefers to pay them. +const LIGHTNING_PREFERENCE: [&str; 2] = ["btc-lightning-bolt11", "btc-lightning-lnurl"]; + +/// On-chain address types in the order Bitkit prefers to pay them. +const ONCHAIN_PREFERENCE: [&str; 4] = ["p2tr", "p2wpkh", "p2sh", "p2pkh"]; + +/// The rail segment of an on-chain Payment Endpoint Identifier, as the apps publish it. +fn network_label(network: bitcoin::Network) -> &'static str { + match network { + bitcoin::Network::Bitcoin => "bitcoin", + bitcoin::Network::Regtest => "regtest", + bitcoin::Network::Signet => "signet", + _ => "testnet", + } +} + +fn preferred( + endpoints: &[PaykitEndpoint], + method_ids: impl IntoIterator, +) -> Option<&PaykitEndpoint> { + method_ids + .into_iter() + .find_map(|id| endpoints.iter().find(|e| e.method_id == id)) +} + +fn lightning_endpoint(endpoints: &[PaykitEndpoint]) -> Option<&PaykitEndpoint> { + preferred(endpoints, LIGHTNING_PREFERENCE.map(String::from)) +} + +fn onchain_endpoint( + endpoints: &[PaykitEndpoint], + network: bitcoin::Network, +) -> Option<&PaykitEndpoint> { + let rail = network_label(network); + preferred( + endpoints, + ONCHAIN_PREFERENCE.map(|kind| format!("btc-{rail}-{kind}")), + ) +} + +/// The cheapest service leg that delivers exactly `amount_sat` to an endpoint the contact +/// published and whose quote still holds at `now_unix`. +fn best_service_leg( + legs: &[ServiceRouteLeg], + endpoints: &[PaykitEndpoint], + amount_sat: u64, + now_unix: u64, +) -> Option { + legs.iter() + .filter(|leg| endpoints.contains(&leg.endpoint)) + .filter(|leg| leg.delivered_sat == amount_sat && leg.debit_sat >= leg.delivered_sat) + .filter(|leg| leg.valid_until_unix == 0 || leg.valid_until_unix > now_unix) + .min_by_key(|leg| leg.debit_sat - leg.delivered_sat) + .cloned() +} + +/// Pick the route for paying `request.amount_sat` to a contact at `now_unix`. +pub fn route_payment( + request: &PaykitRouteRequest, + network: bitcoin::Network, + now_unix: u64, +) -> Result { + let amount = request.amount_sat; + if amount == 0 { + return Err(PaykitRouteError::InvalidRequest { + message: "amount must be positive".into(), + }); + } + let endpoints = &request.contact_endpoints; + let direct = [ + ( + lightning_endpoint(endpoints), + request.lightning_spendable_sat, + ), + ( + onchain_endpoint(endpoints, network), + request.onchain_spendable_sat, + ), + ]; + if let Some((Some(endpoint), _)) = direct + .iter() + .find(|(endpoint, spendable)| endpoint.is_some() && *spendable >= amount) + { + return Ok(PaykitRoute::Direct { + endpoint: (*endpoint).clone(), + }); + } + Ok( + match best_service_leg(&request.service_legs, endpoints, amount, now_unix) { + Some(leg) => PaykitRoute::Service { leg }, + None => PaykitRoute::None { + reason: "No balance can reach an endpoint this contact accepts.".into(), + }, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: u64 = 1_000; + + fn endpoint(method_id: &str) -> PaykitEndpoint { + PaykitEndpoint { + method_id: method_id.into(), + value: "v".into(), + } + } + + fn leg(method_id: &str, delivered: u64, debit: u64, until: u64) -> ServiceRouteLeg { + ServiceRouteLeg { + service: "spark".into(), + endpoint: endpoint(method_id), + delivered_sat: delivered, + debit_sat: debit, + atomicity: "trusted".into(), + valid_until_unix: until, + } + } + + fn request( + endpoints: &[&str], + lightning: u64, + onchain: u64, + legs: Vec, + ) -> PaykitRouteRequest { + PaykitRouteRequest { + amount_sat: 20_000, + contact_endpoints: endpoints.iter().map(|id| endpoint(id)).collect(), + lightning_spendable_sat: lightning, + onchain_spendable_sat: onchain, + service_legs: legs, + } + } + + fn route(request: PaykitRouteRequest) -> PaykitRoute { + route_payment(&request, bitcoin::Network::Regtest, NOW).unwrap() + } + + fn direct(method_id: &str) -> PaykitRoute { + PaykitRoute::Direct { + endpoint: endpoint(method_id), + } + } + + #[test] + fn pays_lightning_first_then_the_preferred_onchain_endpoint() { + let all = [ + "btc-regtest-p2pkh", + "btc-bitcoin-p2tr", + "btc-regtest-p2wpkh", + "btc-lightning-lnurl", + "btc-lightning-bolt11", + ]; + assert_eq!( + route(request(&all, 20_000, 20_000, vec![])), + direct("btc-lightning-bolt11") + ); + assert_eq!( + route(request(&all[..4], 20_000, 0, vec![])), + direct("btc-lightning-lnurl") + ); + assert_eq!( + route(request(&all, 19_999, 20_000, vec![])), + direct("btc-regtest-p2wpkh") + ); + } + + #[test] + fn pays_directly_before_any_service_leg() { + let legs = vec![leg("btc-regtest-p2tr", 20_000, 20_000, 0)]; + assert_eq!( + route(request(&["btc-regtest-p2tr"], 0, 20_000, legs)), + direct("btc-regtest-p2tr") + ); + } + + #[test] + fn withdraws_through_the_cheapest_live_leg_to_a_published_endpoint() { + let legs = vec![ + leg("btc-regtest-p2wpkh", 20_000, 20_900, 0), + leg("btc-regtest-p2wpkh", 20_000, 20_400, NOW + 1), + leg("btc-regtest-p2tr", 20_000, 20_001, 0), + leg("btc-regtest-p2wpkh", 20_000, 20_100, NOW), + leg("btc-regtest-p2wpkh", 19_000, 19_100, 0), + leg("btc-regtest-p2wpkh", 20_000, 19_999, 0), + ]; + let PaykitRoute::Service { leg } = route(request(&["btc-regtest-p2wpkh"], 0, 0, legs)) + else { + panic!("expected a service route"); + }; + assert_eq!(leg.debit_sat, 20_400); + } + + #[test] + fn explains_when_nothing_reaches_the_contact() { + assert_eq!( + route(request(&["btc-lightning-bolt11"], 19_999, 0, vec![])), + PaykitRoute::None { + reason: "No balance can reach an endpoint this contact accepts.".into() + } + ); + assert!(route_payment( + &PaykitRouteRequest { + amount_sat: 0, + ..request(&[], 0, 0, vec![]) + }, + bitcoin::Network::Regtest, + NOW + ) + .is_err()); + } +} diff --git a/src/modules/paykit_route/types.rs b/src/modules/paykit_route/types.rs new file mode 100644 index 00000000..a5b06559 --- /dev/null +++ b/src/modules/paykit_route/types.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +/// A Payment Endpoint the contact published: its Payment Endpoint Identifier, such as +/// `btc-lightning-bolt11` or `btc-bitcoin-p2tr`, and the value a payer pays to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PaykitEndpoint { + pub method_id: String, + pub value: String, +} + +/// What the wallet knows when it pays a Paykit contact: the amount, the endpoints the contact +/// published and what the wallet can spend on each rail. +#[derive(Debug, Clone, uniffi::Record)] +pub struct PaykitRouteRequest { + pub amount_sat: u64, + /// The contact's current Payment Endpoints, as Paykit resolved them. + pub contact_endpoints: Vec, + /// What the wallet can pay over Lightning, its Spark balance included. + pub lightning_spendable_sat: u64, + pub onchain_spendable_sat: u64, + /// Legs through services the wallet holds a balance on, such as a Spark withdrawal to the + /// contact's on-chain address, each already quoted by the app's engine for that service. + pub service_legs: Vec, +} + +/// A one-leg route through a service the wallet holds a balance on, such as a Spark withdrawal +/// to the contact's on-chain address. The app quotes it and runs it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct ServiceRouteLeg { + /// The service, such as `spark`. + pub service: String, + /// The contact endpoint the service delivers to. It must be one the contact published. + pub endpoint: PaykitEndpoint, + /// What the contact receives. + pub delivered_sat: u64, + /// What the wallet's balance on the service is debited. + pub debit_sat: u64, + /// Who the wallet trusts to complete the leg: `trusted` for Spark's operators. + pub atomicity: String, + /// When the quote stops holding, or 0 when it does not expire. + pub valid_until_unix: u64, +} + +/// The route the router picked. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum PaykitRoute { + /// The wallet can pay this endpoint from its own balance. + Direct { endpoint: PaykitEndpoint }, + /// A service the wallet holds a balance on delivers to the contact. The app runs the leg. + Service { leg: ServiceRouteLeg }, + /// Nothing can pay this contact. `reason` says why, for the user. + None { reason: String }, +} From fd6b27bca2ea038cfe9ba930fd5142103f10d9d5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 19:18:19 +0100 Subject: [PATCH 02/12] chore: regenerate ios bindings for the paykit router --- bindings/ios/bitkitcore.swift | 584 ++++++++++++++++++++++++++++++++++ bindings/ios/bitkitcoreFFI.h | 11 + 2 files changed, 595 insertions(+) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 73267bf6..0038d07d 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -11575,6 +11575,202 @@ public func FfiConverterTypePassportAccountExport_lower(_ value: PassportAccount } +/** + * A Payment Endpoint the contact published: its Payment Endpoint Identifier, such as + * `btc-lightning-bolt11` or `btc-bitcoin-p2tr`, and the value a payer pays to. + */ +public struct PaykitEndpoint { + public var methodId: String + public var value: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(methodId: String, value: String) { + self.methodId = methodId + self.value = value + } +} + +#if compiler(>=6) +extension PaykitEndpoint: Sendable {} +#endif + + +extension PaykitEndpoint: Equatable, Hashable { + public static func ==(lhs: PaykitEndpoint, rhs: PaykitEndpoint) -> Bool { + if lhs.methodId != rhs.methodId { + return false + } + if lhs.value != rhs.value { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(methodId) + hasher.combine(value) + } +} + +extension PaykitEndpoint: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePaykitEndpoint: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PaykitEndpoint { + return + try PaykitEndpoint( + methodId: FfiConverterString.read(from: &buf), + value: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: PaykitEndpoint, into buf: inout [UInt8]) { + FfiConverterString.write(value.methodId, into: &buf) + FfiConverterString.write(value.value, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitEndpoint_lift(_ buf: RustBuffer) throws -> PaykitEndpoint { + return try FfiConverterTypePaykitEndpoint.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitEndpoint_lower(_ value: PaykitEndpoint) -> RustBuffer { + return FfiConverterTypePaykitEndpoint.lower(value) +} + + +/** + * What the wallet knows when it pays a Paykit contact: the amount, the endpoints the contact + * published and what the wallet can spend on each rail. + */ +public struct PaykitRouteRequest { + public var amountSat: UInt64 + /** + * The contact's current Payment Endpoints, as Paykit resolved them. + */ + public var contactEndpoints: [PaykitEndpoint] + /** + * What the wallet can pay over Lightning, its Spark balance included. + */ + public var lightningSpendableSat: UInt64 + public var onchainSpendableSat: UInt64 + /** + * Legs through services the wallet holds a balance on, such as a Spark withdrawal to the + * contact's on-chain address, each already quoted by the app's engine for that service. + */ + public var serviceLegs: [ServiceRouteLeg] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(amountSat: UInt64, + /** + * The contact's current Payment Endpoints, as Paykit resolved them. + */contactEndpoints: [PaykitEndpoint], + /** + * What the wallet can pay over Lightning, its Spark balance included. + */lightningSpendableSat: UInt64, onchainSpendableSat: UInt64, + /** + * Legs through services the wallet holds a balance on, such as a Spark withdrawal to the + * contact's on-chain address, each already quoted by the app's engine for that service. + */serviceLegs: [ServiceRouteLeg]) { + self.amountSat = amountSat + self.contactEndpoints = contactEndpoints + self.lightningSpendableSat = lightningSpendableSat + self.onchainSpendableSat = onchainSpendableSat + self.serviceLegs = serviceLegs + } +} + +#if compiler(>=6) +extension PaykitRouteRequest: Sendable {} +#endif + + +extension PaykitRouteRequest: Equatable, Hashable { + public static func ==(lhs: PaykitRouteRequest, rhs: PaykitRouteRequest) -> Bool { + if lhs.amountSat != rhs.amountSat { + return false + } + if lhs.contactEndpoints != rhs.contactEndpoints { + return false + } + if lhs.lightningSpendableSat != rhs.lightningSpendableSat { + return false + } + if lhs.onchainSpendableSat != rhs.onchainSpendableSat { + return false + } + if lhs.serviceLegs != rhs.serviceLegs { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(amountSat) + hasher.combine(contactEndpoints) + hasher.combine(lightningSpendableSat) + hasher.combine(onchainSpendableSat) + hasher.combine(serviceLegs) + } +} + +extension PaykitRouteRequest: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePaykitRouteRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PaykitRouteRequest { + return + try PaykitRouteRequest( + amountSat: FfiConverterUInt64.read(from: &buf), + contactEndpoints: FfiConverterSequenceTypePaykitEndpoint.read(from: &buf), + lightningSpendableSat: FfiConverterUInt64.read(from: &buf), + onchainSpendableSat: FfiConverterUInt64.read(from: &buf), + serviceLegs: FfiConverterSequenceTypeServiceRouteLeg.read(from: &buf) + ) + } + + public static func write(_ value: PaykitRouteRequest, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.amountSat, into: &buf) + FfiConverterSequenceTypePaykitEndpoint.write(value.contactEndpoints, into: &buf) + FfiConverterUInt64.write(value.lightningSpendableSat, into: &buf) + FfiConverterUInt64.write(value.onchainSpendableSat, into: &buf) + FfiConverterSequenceTypeServiceRouteLeg.write(value.serviceLegs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRouteRequest_lift(_ buf: RustBuffer) throws -> PaykitRouteRequest { + return try FfiConverterTypePaykitRouteRequest.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRouteRequest_lower(_ value: PaykitRouteRequest) -> RustBuffer { + return FfiConverterTypePaykitRouteRequest.lower(value) +} + + public struct PreActivityMetadata { public var walletId: String public var paymentId: String @@ -12206,6 +12402,150 @@ public func FfiConverterTypeReverseSwapResponse_lower(_ value: ReverseSwapRespon } +/** + * A one-leg route through a service the wallet holds a balance on, such as a Spark withdrawal + * to the contact's on-chain address. The app quotes it and runs it. + */ +public struct ServiceRouteLeg { + /** + * The service, such as `spark`. + */ + public var service: String + /** + * The contact endpoint the service delivers to. It must be one the contact published. + */ + public var endpoint: PaykitEndpoint + /** + * What the contact receives. + */ + public var deliveredSat: UInt64 + /** + * What the wallet's balance on the service is debited. + */ + public var debitSat: UInt64 + /** + * Who the wallet trusts to complete the leg: `trusted` for Spark's operators. + */ + public var atomicity: String + /** + * When the quote stops holding, or 0 when it does not expire. + */ + public var validUntilUnix: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * The service, such as `spark`. + */service: String, + /** + * The contact endpoint the service delivers to. It must be one the contact published. + */endpoint: PaykitEndpoint, + /** + * What the contact receives. + */deliveredSat: UInt64, + /** + * What the wallet's balance on the service is debited. + */debitSat: UInt64, + /** + * Who the wallet trusts to complete the leg: `trusted` for Spark's operators. + */atomicity: String, + /** + * When the quote stops holding, or 0 when it does not expire. + */validUntilUnix: UInt64) { + self.service = service + self.endpoint = endpoint + self.deliveredSat = deliveredSat + self.debitSat = debitSat + self.atomicity = atomicity + self.validUntilUnix = validUntilUnix + } +} + +#if compiler(>=6) +extension ServiceRouteLeg: Sendable {} +#endif + + +extension ServiceRouteLeg: Equatable, Hashable { + public static func ==(lhs: ServiceRouteLeg, rhs: ServiceRouteLeg) -> Bool { + if lhs.service != rhs.service { + return false + } + if lhs.endpoint != rhs.endpoint { + return false + } + if lhs.deliveredSat != rhs.deliveredSat { + return false + } + if lhs.debitSat != rhs.debitSat { + return false + } + if lhs.atomicity != rhs.atomicity { + return false + } + if lhs.validUntilUnix != rhs.validUntilUnix { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(service) + hasher.combine(endpoint) + hasher.combine(deliveredSat) + hasher.combine(debitSat) + hasher.combine(atomicity) + hasher.combine(validUntilUnix) + } +} + +extension ServiceRouteLeg: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeServiceRouteLeg: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ServiceRouteLeg { + return + try ServiceRouteLeg( + service: FfiConverterString.read(from: &buf), + endpoint: FfiConverterTypePaykitEndpoint.read(from: &buf), + deliveredSat: FfiConverterUInt64.read(from: &buf), + debitSat: FfiConverterUInt64.read(from: &buf), + atomicity: FfiConverterString.read(from: &buf), + validUntilUnix: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: ServiceRouteLeg, into buf: inout [UInt8]) { + FfiConverterString.write(value.service, into: &buf) + FfiConverterTypePaykitEndpoint.write(value.endpoint, into: &buf) + FfiConverterUInt64.write(value.deliveredSat, into: &buf) + FfiConverterUInt64.write(value.debitSat, into: &buf) + FfiConverterString.write(value.atomicity, into: &buf) + FfiConverterUInt64.write(value.validUntilUnix, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeServiceRouteLeg_lift(_ buf: RustBuffer) throws -> ServiceRouteLeg { + return try FfiConverterTypeServiceRouteLeg.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeServiceRouteLeg_lower(_ value: ServiceRouteLeg) -> RustBuffer { + return FfiConverterTypeServiceRouteLeg.lower(value) +} + + /** * Result from querying a single Bitcoin address. */ @@ -24615,6 +24955,184 @@ extension PassphraseResponse: Codable {} +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * The route the router picked. + */ + +public enum PaykitRoute { + + /** + * The wallet can pay this endpoint from its own balance. + */ + case direct(endpoint: PaykitEndpoint + ) + /** + * A service the wallet holds a balance on delivers to the contact. The app runs the leg. + */ + case service(leg: ServiceRouteLeg + ) + /** + * Nothing can pay this contact. `reason` says why, for the user. + */ + case none(reason: String + ) +} + + +#if compiler(>=6) +extension PaykitRoute: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePaykitRoute: FfiConverterRustBuffer { + typealias SwiftType = PaykitRoute + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PaykitRoute { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .direct(endpoint: try FfiConverterTypePaykitEndpoint.read(from: &buf) + ) + + case 2: return .service(leg: try FfiConverterTypeServiceRouteLeg.read(from: &buf) + ) + + case 3: return .none(reason: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PaykitRoute, into buf: inout [UInt8]) { + switch value { + + + case let .direct(endpoint): + writeInt(&buf, Int32(1)) + FfiConverterTypePaykitEndpoint.write(endpoint, into: &buf) + + + case let .service(leg): + writeInt(&buf, Int32(2)) + FfiConverterTypeServiceRouteLeg.write(leg, into: &buf) + + + case let .none(reason): + writeInt(&buf, Int32(3)) + FfiConverterString.write(reason, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRoute_lift(_ buf: RustBuffer) throws -> PaykitRoute { + return try FfiConverterTypePaykitRoute.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRoute_lower(_ value: PaykitRoute) -> RustBuffer { + return FfiConverterTypePaykitRoute.lower(value) +} + + +extension PaykitRoute: Equatable, Hashable {} + +extension PaykitRoute: Codable {} + + + + + + + +public enum PaykitRouteError: Swift.Error { + + + + case InvalidRequest(message: String + ) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePaykitRouteError: FfiConverterRustBuffer { + typealias SwiftType = PaykitRouteError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PaykitRouteError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidRequest( + message: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PaykitRouteError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InvalidRequest(message): + writeInt(&buf, Int32(1)) + FfiConverterString.write(message, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRouteError_lift(_ buf: RustBuffer) throws -> PaykitRouteError { + return try FfiConverterTypePaykitRouteError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRouteError_lower(_ value: PaykitRouteError) -> RustBuffer { + return FfiConverterTypePaykitRouteError.lower(value) +} + + +extension PaykitRouteError: Equatable, Hashable {} + +extension PaykitRouteError: Codable {} + + + + +extension PaykitRouteError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -29854,6 +30372,31 @@ fileprivate struct FfiConverterSequenceTypePassportAccount: FfiConverterRustBuff } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypePaykitEndpoint: FfiConverterRustBuffer { + typealias SwiftType = [PaykitEndpoint] + + public static func write(_ value: [PaykitEndpoint], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypePaykitEndpoint.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PaykitEndpoint] { + let len: Int32 = try readInt(&buf) + var seq = [PaykitEndpoint]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypePaykitEndpoint.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -29904,6 +30447,31 @@ fileprivate struct FfiConverterSequenceTypePubkyProfileLink: FfiConverterRustBuf } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeServiceRouteLeg: FfiConverterRustBuffer { + typealias SwiftType = [ServiceRouteLeg] + + public static func write(_ value: [ServiceRouteLeg], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeServiceRouteLeg.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ServiceRouteLeg] { + let len: Int32 = try readInt(&buf) + var seq = [ServiceRouteLeg]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeServiceRouteLeg.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -32354,6 +32922,19 @@ public func passportParseAccountExport(data: Data)throws -> PassportAccountExpo ) }) } +/** + * Pick how to pay a Paykit contact: `Direct` when a balance the wallet holds reaches one of the + * contact's endpoints, otherwise the cheapest of the request's service legs, or `None` with a + * reason. The user never picks the route. + */ +public func paykitRoutePayment(request: PaykitRouteRequest, network: Network)throws -> PaykitRoute { + return try FfiConverterTypePaykitRoute_lift(try rustCallWithError(FfiConverterTypePaykitRouteError_lift) { + uniffi_bitkitcore_fn_func_paykit_route_payment( + FfiConverterTypePaykitRouteRequest_lower(request), + FfiConverterTypeNetwork_lower(network),$0 + ) +}) +} /** * Decode pre-activity metadata from Core's canonical backup JSON, defaulting a * missing/empty wallet id to [`DEFAULT_WALLET_ID`]. @@ -34068,6 +34649,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_passport_parse_account_export() != 62096) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_paykit_route_payment() != 42294) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_pre_activity_metadata_from_json() != 61978) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 01724b99..1aa74234 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -1516,6 +1516,11 @@ RustBuffer uniffi_bitkitcore_fn_func_parse_pubky_auth_url(RustBuffer auth_url, R RustBuffer uniffi_bitkitcore_fn_func_passport_parse_account_export(RustBuffer data, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PAYKIT_ROUTE_PAYMENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PAYKIT_ROUTE_PAYMENT +RustBuffer uniffi_bitkitcore_fn_func_paykit_route_payment(RustBuffer request, RustBuffer network, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PRE_ACTIVITY_METADATA_FROM_JSON #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PRE_ACTIVITY_METADATA_FROM_JSON RustBuffer uniffi_bitkitcore_fn_func_pre_activity_metadata_from_json(RustBuffer json, RustCallStatus *_Nonnull out_status @@ -3099,6 +3104,12 @@ uint16_t uniffi_bitkitcore_checksum_func_parse_pubky_auth_url(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PASSPORT_PARSE_ACCOUNT_EXPORT uint16_t uniffi_bitkitcore_checksum_func_passport_parse_account_export(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PAYKIT_ROUTE_PAYMENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PAYKIT_ROUTE_PAYMENT +uint16_t uniffi_bitkitcore_checksum_func_paykit_route_payment(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PRE_ACTIVITY_METADATA_FROM_JSON From 36e83168f79bf72c86be6a2ce62fd682f1cb6496 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 19:24:52 +0100 Subject: [PATCH 03/12] chore: prepare 0.8.0-spark.1 prerelease --- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 ++-- bindings/android/gradle.properties | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 667f6b21..5755d3dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-rc11" +version = "0.8.0-spark.1" dependencies = [ "aes-gcm", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 3e898294..3893a776 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-rc11" +version = "0.8.0-spark.1" edition = "2021" rust-version = "1.90" diff --git a/Package.swift b/Package.swift index 6b2ce47f..52a35f91 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.8.0-rc11" -let checksum = "f7ef2ffb625498c651111cb0f5a177893c5abd91c89b077e11abf0ee289cd544" +let tag = "v0.8.0-spark.1" +let checksum = "6eb3c05aa5dac4d982598c7ded01f319e51321a65ec58d61764ee97fb3cbbe8a" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 82cf1255..b6eca6a0 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.8.0-rc11 +version=0.8.0-spark.1 From d9e77e51b3f9d18a5d64064e4d1b71befdbc0f1b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 19:32:12 +0100 Subject: [PATCH 04/12] fix: route service legs only to on-chain endpoints of the wallet's network --- bindings/ios/bitkitcore.swift | 14 +++++++++++--- src/modules/paykit_route/README.md | 5 +++-- src/modules/paykit_route/router.rs | 26 +++++++++++++++++++++++--- src/modules/paykit_route/types.rs | 4 +++- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 0038d07d..f5a1cf66 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -11662,9 +11662,13 @@ public struct PaykitRouteRequest { */ public var contactEndpoints: [PaykitEndpoint] /** - * What the wallet can pay over Lightning, its Spark balance included. + * What the wallet can pay over Lightning, its Spark balance included, net of the fees a + * payment of `amount_sat` costs. */ public var lightningSpendableSat: UInt64 + /** + * What the wallet can pay on chain, net of the miner fee a payment of `amount_sat` costs. + */ public var onchainSpendableSat: UInt64 /** * Legs through services the wallet holds a balance on, such as a Spark withdrawal to the @@ -11679,8 +11683,12 @@ public struct PaykitRouteRequest { * The contact's current Payment Endpoints, as Paykit resolved them. */contactEndpoints: [PaykitEndpoint], /** - * What the wallet can pay over Lightning, its Spark balance included. - */lightningSpendableSat: UInt64, onchainSpendableSat: UInt64, + * What the wallet can pay over Lightning, its Spark balance included, net of the fees a + * payment of `amount_sat` costs. + */lightningSpendableSat: UInt64, + /** + * What the wallet can pay on chain, net of the miner fee a payment of `amount_sat` costs. + */onchainSpendableSat: UInt64, /** * Legs through services the wallet holds a balance on, such as a Spark withdrawal to the * contact's on-chain address, each already quoted by the app's engine for that service. diff --git a/src/modules/paykit_route/README.md b/src/modules/paykit_route/README.md index e3fd7433..364a5387 100644 --- a/src/modules/paykit_route/README.md +++ b/src/modules/paykit_route/README.md @@ -10,10 +10,11 @@ screen shows it. wallet pays it directly: over Lightning first (`btc-lightning-bolt11`, then `btc-lightning-lnurl`) when `lightning_spendable_sat` covers the amount, else on chain (`btc-{network}-p2tr`, `p2wpkh`, `p2sh`, `p2pkh`, in that order) when `onchain_spendable_sat` - does. Spark's balance counts as Lightning-spendable, so paying a Lightning endpoint from Spark + does. Both amounts are net of the fees the payment costs. Spark's balance counts as Lightning-spendable, so paying a Lightning endpoint from Spark needs no leg. 2. **Service leg.** Otherwise the router keeps the cheapest of `service_legs` that delivers exactly - the amount to an endpoint the contact published and whose quote has not expired. A leg is a + the amount to an on-chain endpoint for the wallet's network the contact published and whose quote + has not expired. A leg is a one-step route through a service the wallet holds a balance on, such as a Spark withdrawal to the contact's on-chain address. The app quotes each leg with that service's engine and runs the one the router returns; the router never talks to the service. `atomicity` states who the leg diff --git a/src/modules/paykit_route/router.rs b/src/modules/paykit_route/router.rs index 8f35e362..e7fa9b30 100644 --- a/src/modules/paykit_route/router.rs +++ b/src/modules/paykit_route/router.rs @@ -45,16 +45,23 @@ fn onchain_endpoint( ) } -/// The cheapest service leg that delivers exactly `amount_sat` to an endpoint the contact -/// published and whose quote still holds at `now_unix`. +/// The cheapest service leg that delivers exactly `amount_sat` to an on-chain endpoint for +/// `network` the contact published and whose quote still holds at `now_unix`. fn best_service_leg( legs: &[ServiceRouteLeg], endpoints: &[PaykitEndpoint], amount_sat: u64, + network: bitcoin::Network, now_unix: u64, ) -> Option { + let rail = network_label(network); legs.iter() .filter(|leg| endpoints.contains(&leg.endpoint)) + .filter(|leg| { + ONCHAIN_PREFERENCE + .iter() + .any(|kind| leg.endpoint.method_id == format!("btc-{rail}-{kind}")) + }) .filter(|leg| leg.delivered_sat == amount_sat && leg.debit_sat >= leg.delivered_sat) .filter(|leg| leg.valid_until_unix == 0 || leg.valid_until_unix > now_unix) .min_by_key(|leg| leg.debit_sat - leg.delivered_sat) @@ -93,7 +100,7 @@ pub fn route_payment( }); } Ok( - match best_service_leg(&request.service_legs, endpoints, amount, now_unix) { + match best_service_leg(&request.service_legs, endpoints, amount, network, now_unix) { Some(leg) => PaykitRoute::Service { leg }, None => PaykitRoute::None { reason: "No balance can reach an endpoint this contact accepts.".into(), @@ -200,6 +207,19 @@ mod tests { assert_eq!(leg.debit_sat, 20_400); } + #[test] + fn ignores_legs_to_endpoints_off_this_network_or_off_chain() { + let legs = vec![ + leg("btc-bitcoin-p2tr", 20_000, 20_001, 0), + leg("btc-lightning-bolt11", 20_000, 20_001, 0), + ]; + let published = ["btc-bitcoin-p2tr", "btc-lightning-bolt11"]; + assert!(matches!( + route(request(&published, 0, 0, legs)), + PaykitRoute::None { .. } + )); + } + #[test] fn explains_when_nothing_reaches_the_contact() { assert_eq!( diff --git a/src/modules/paykit_route/types.rs b/src/modules/paykit_route/types.rs index a5b06559..06d0b3d2 100644 --- a/src/modules/paykit_route/types.rs +++ b/src/modules/paykit_route/types.rs @@ -15,8 +15,10 @@ pub struct PaykitRouteRequest { pub amount_sat: u64, /// The contact's current Payment Endpoints, as Paykit resolved them. pub contact_endpoints: Vec, - /// What the wallet can pay over Lightning, its Spark balance included. + /// What the wallet can pay over Lightning, its Spark balance included, net of the fees a + /// payment of `amount_sat` costs. pub lightning_spendable_sat: u64, + /// What the wallet can pay on chain, net of the miner fee a payment of `amount_sat` costs. pub onchain_spendable_sat: u64, /// Legs through services the wallet holds a balance on, such as a Spark withdrawal to the /// contact's on-chain address, each already quoted by the app's engine for that service. From c199b80753ebfe39c59c23ce7af4b028f3c23d46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 20:19:07 +0100 Subject: [PATCH 05/12] fix: name the route error field so kotlin bindings compile --- src/modules/paykit_route/errors.rs | 4 ++-- src/modules/paykit_route/router.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/paykit_route/errors.rs b/src/modules/paykit_route/errors.rs index a0694ba4..6875de82 100644 --- a/src/modules/paykit_route/errors.rs +++ b/src/modules/paykit_route/errors.rs @@ -3,6 +3,6 @@ use thiserror::Error; #[derive(uniffi::Error, Debug, Error, PartialEq, Eq)] #[non_exhaustive] pub enum PaykitRouteError { - #[error("Invalid route request: {message}")] - InvalidRequest { message: String }, + #[error("Invalid route request: {error_details}")] + InvalidRequest { error_details: String }, } diff --git a/src/modules/paykit_route/router.rs b/src/modules/paykit_route/router.rs index e7fa9b30..7ac74d3d 100644 --- a/src/modules/paykit_route/router.rs +++ b/src/modules/paykit_route/router.rs @@ -77,7 +77,7 @@ pub fn route_payment( let amount = request.amount_sat; if amount == 0 { return Err(PaykitRouteError::InvalidRequest { - message: "amount must be positive".into(), + error_details: "amount must be positive".into(), }); } let endpoints = &request.contact_endpoints; From e72a49fd1e5b35b1009790e0687d813203e95a03 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 20:30:22 +0100 Subject: [PATCH 06/12] chore: prepare 0.8.0-spark.2 prerelease --- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 ++-- bindings/android/gradle.properties | 2 +- bindings/ios/bitkitcore.swift | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5755d3dd..9564bf5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-spark.1" +version = "0.8.0-spark.2" dependencies = [ "aes-gcm", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 3893a776..5bccf988 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-spark.1" +version = "0.8.0-spark.2" edition = "2021" rust-version = "1.90" diff --git a/Package.swift b/Package.swift index 52a35f91..a5906140 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.8.0-spark.1" -let checksum = "6eb3c05aa5dac4d982598c7ded01f319e51321a65ec58d61764ee97fb3cbbe8a" +let tag = "v0.8.0-spark.2" +let checksum = "78944dc3f2be4a99b85da634ef533846a03d5f5b2f9c64aede3b2cef07ce1815" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index b6eca6a0..b19426ac 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.8.0-spark.1 +version=0.8.0-spark.2 diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index f5a1cf66..8e81b9a2 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -25068,7 +25068,7 @@ public enum PaykitRouteError: Swift.Error { - case InvalidRequest(message: String + case InvalidRequest(errorDetails: String ) } @@ -25087,7 +25087,7 @@ public struct FfiConverterTypePaykitRouteError: FfiConverterRustBuffer { case 1: return .InvalidRequest( - message: try FfiConverterString.read(from: &buf) + errorDetails: try FfiConverterString.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -25101,9 +25101,9 @@ public struct FfiConverterTypePaykitRouteError: FfiConverterRustBuffer { - case let .InvalidRequest(message): + case let .InvalidRequest(errorDetails): writeInt(&buf, Int32(1)) - FfiConverterString.write(message, into: &buf) + FfiConverterString.write(errorDetails, into: &buf) } } From 5b75abf20c0c55d0805d274fbed08f0109b50899 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 23:14:50 +0100 Subject: [PATCH 07/12] fix: route only lightning invoices the wallet can pay 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. --- bindings/ios/bitkitcore.swift | 9 +- src/modules/paykit_route/README.md | 11 +- src/modules/paykit_route/router.rs | 171 +++++++++++++++++++++++++---- src/modules/paykit_route/types.rs | 6 +- 4 files changed, 167 insertions(+), 30 deletions(-) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 8e81b9a2..dbc969e9 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -11658,7 +11658,8 @@ public func FfiConverterTypePaykitEndpoint_lower(_ value: PaykitEndpoint) -> Rus public struct PaykitRouteRequest { public var amountSat: UInt64 /** - * The contact's current Payment Endpoints, as Paykit resolved them. + * All of the contact's current Payment Endpoints, as Paykit resolved them. Pass the full list: + * the router picks the endpoint, checking BOLT11 invoices for network, expiry and amount. */ public var contactEndpoints: [PaykitEndpoint] /** @@ -11680,7 +11681,8 @@ public struct PaykitRouteRequest { // declare one manually. public init(amountSat: UInt64, /** - * The contact's current Payment Endpoints, as Paykit resolved them. + * All of the contact's current Payment Endpoints, as Paykit resolved them. Pass the full list: + * the router picks the endpoint, checking BOLT11 invoices for network, expiry and amount. */contactEndpoints: [PaykitEndpoint], /** * What the wallet can pay over Lightning, its Spark balance included, net of the fees a @@ -24982,7 +24984,8 @@ public enum PaykitRoute { case service(leg: ServiceRouteLeg ) /** - * Nothing can pay this contact. `reason` says why, for the user. + * Nothing can pay this contact. `reason` is English detail for logs; the app shows its own + * localized text. */ case none(reason: String ) diff --git a/src/modules/paykit_route/README.md b/src/modules/paykit_route/README.md index 364a5387..4839a9ce 100644 --- a/src/modules/paykit_route/README.md +++ b/src/modules/paykit_route/README.md @@ -10,8 +10,10 @@ screen shows it. wallet pays it directly: over Lightning first (`btc-lightning-bolt11`, then `btc-lightning-lnurl`) when `lightning_spendable_sat` covers the amount, else on chain (`btc-{network}-p2tr`, `p2wpkh`, `p2sh`, `p2pkh`, in that order) when `onchain_spendable_sat` - does. Both amounts are net of the fees the payment costs. Spark's balance counts as Lightning-spendable, so paying a Lightning endpoint from Spark - needs no leg. + does. Both amounts are net of the fees the payment costs. Spark's balance counts as + Lightning-spendable, so paying a Lightning endpoint from Spark needs no leg. A BOLT11 invoice + counts only when it is for the wallet's network, unexpired and amountless or for exactly the + amount; an LNURL only needs a value, since checking it takes a network call. 2. **Service leg.** Otherwise the router keeps the cheapest of `service_legs` that delivers exactly the amount to an on-chain endpoint for the wallet's network the contact published and whose quote has not expired. A leg is a @@ -19,8 +21,9 @@ screen shows it. the contact's on-chain address. The app quotes each leg with that service's engine and runs the one the router returns; the router never talks to the service. `atomicity` states who the leg trusts (`trusted` for Spark's operators). -3. **None.** Nothing reaches the contact; `reason` says why, for the user. +3. **None.** Nothing reaches the contact. `reason` is English detail for logs; the app shows its + own localized text. The rail segment of an on-chain Payment Endpoint Identifier names the network the apps publish for (`bitcoin`, `testnet`, `signet`, `regtest`), so a contact's endpoint for another network is never -paid. +paid. The spec has no Testnet4 rail, so a Testnet4 wallet routes nothing on chain. diff --git a/src/modules/paykit_route/router.rs b/src/modules/paykit_route/router.rs index 7ac74d3d..96e4c7d6 100644 --- a/src/modules/paykit_route/router.rs +++ b/src/modules/paykit_route/router.rs @@ -4,6 +4,9 @@ use super::errors::PaykitRouteError; use super::types::{PaykitEndpoint, PaykitRoute, PaykitRouteRequest, ServiceRouteLeg}; +use lightning_invoice::{Bolt11Invoice, Currency}; +use std::str::FromStr; +use std::time::Duration; /// Lightning Payment Endpoint Identifiers in the order Bitkit prefers to pay them. const LIGHTNING_PREFERENCE: [&str; 2] = ["btc-lightning-bolt11", "btc-lightning-lnurl"]; @@ -11,16 +14,30 @@ const LIGHTNING_PREFERENCE: [&str; 2] = ["btc-lightning-bolt11", "btc-lightning- /// On-chain address types in the order Bitkit prefers to pay them. const ONCHAIN_PREFERENCE: [&str; 4] = ["p2tr", "p2wpkh", "p2sh", "p2pkh"]; -/// The rail segment of an on-chain Payment Endpoint Identifier, as the apps publish it. -fn network_label(network: bitcoin::Network) -> &'static str { +/// The rail segment of an on-chain Payment Endpoint Identifier, as the apps publish it. The +/// spec has no Testnet4 rail, so a Testnet4 wallet routes nothing on chain. +fn network_label(network: bitcoin::Network) -> Option<&'static str> { match network { - bitcoin::Network::Bitcoin => "bitcoin", - bitcoin::Network::Regtest => "regtest", - bitcoin::Network::Signet => "signet", - _ => "testnet", + bitcoin::Network::Bitcoin => Some("bitcoin"), + bitcoin::Network::Regtest => Some("regtest"), + bitcoin::Network::Signet => Some("signet"), + bitcoin::Network::Testnet => Some("testnet"), + _ => None, } } +/// The wallet's on-chain Payment Endpoint Identifiers, in the order Bitkit prefers to pay them. +fn onchain_method_ids(network: bitcoin::Network) -> Vec { + network_label(network) + .map(|rail| { + ONCHAIN_PREFERENCE + .iter() + .map(|kind| format!("btc-{rail}-{kind}")) + .collect() + }) + .unwrap_or_default() +} + fn preferred( endpoints: &[PaykitEndpoint], method_ids: impl IntoIterator, @@ -30,19 +47,46 @@ fn preferred( .find_map(|id| endpoints.iter().find(|e| e.method_id == id)) } -fn lightning_endpoint(endpoints: &[PaykitEndpoint]) -> Option<&PaykitEndpoint> { - preferred(endpoints, LIGHTNING_PREFERENCE.map(String::from)) +/// Whether a Lightning endpoint's value can pay `amount_sat` now. A BOLT11 invoice must be for +/// the wallet's network, unexpired at `now_unix` and amountless or for exactly the amount. An +/// LNURL is only checked for a value, since checking it needs a network call. +fn lightning_payable( + endpoint: &PaykitEndpoint, + amount_sat: u64, + network: bitcoin::Network, + now_unix: u64, +) -> bool { + if endpoint.method_id != "btc-lightning-bolt11" { + return !endpoint.value.trim().is_empty(); + } + let Ok(invoice) = Bolt11Invoice::from_str(endpoint.value.trim()) else { + return false; + }; + invoice.currency() == Currency::from(network) + && !invoice.would_expire(Duration::from_secs(now_unix)) + && invoice + .amount_milli_satoshis() + .is_none_or(|msat| msat == amount_sat.saturating_mul(1000)) +} + +fn lightning_endpoint( + endpoints: &[PaykitEndpoint], + amount_sat: u64, + network: bitcoin::Network, + now_unix: u64, +) -> Option<&PaykitEndpoint> { + LIGHTNING_PREFERENCE.iter().find_map(|id| { + endpoints + .iter() + .find(|e| e.method_id == *id && lightning_payable(e, amount_sat, network, now_unix)) + }) } fn onchain_endpoint( endpoints: &[PaykitEndpoint], network: bitcoin::Network, ) -> Option<&PaykitEndpoint> { - let rail = network_label(network); - preferred( - endpoints, - ONCHAIN_PREFERENCE.map(|kind| format!("btc-{rail}-{kind}")), - ) + preferred(endpoints, onchain_method_ids(network)) } /// The cheapest service leg that delivers exactly `amount_sat` to an on-chain endpoint for @@ -54,14 +98,10 @@ fn best_service_leg( network: bitcoin::Network, now_unix: u64, ) -> Option { - let rail = network_label(network); + let onchain = onchain_method_ids(network); legs.iter() .filter(|leg| endpoints.contains(&leg.endpoint)) - .filter(|leg| { - ONCHAIN_PREFERENCE - .iter() - .any(|kind| leg.endpoint.method_id == format!("btc-{rail}-{kind}")) - }) + .filter(|leg| onchain.contains(&leg.endpoint.method_id)) .filter(|leg| leg.delivered_sat == amount_sat && leg.debit_sat >= leg.delivered_sat) .filter(|leg| leg.valid_until_unix == 0 || leg.valid_until_unix > now_unix) .min_by_key(|leg| leg.debit_sat - leg.delivered_sat) @@ -83,7 +123,7 @@ pub fn route_payment( let endpoints = &request.contact_endpoints; let direct = [ ( - lightning_endpoint(endpoints), + lightning_endpoint(endpoints, amount, network, now_unix), request.lightning_spendable_sat, ), ( @@ -112,13 +152,41 @@ pub fn route_payment( #[cfg(test)] mod tests { use super::*; + use bitcoin::hashes::{sha256, Hash as _}; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning_invoice::{InvoiceBuilder, PaymentSecret}; const NOW: u64 = 1_000; + /// A signed invoice created at `NOW - 10` that expires `expiry_secs` later. + fn bolt11(currency: Currency, amount_msat: Option, expiry_secs: u64) -> String { + let secp = Secp256k1::new(); + let key = SecretKey::from_slice(&[0xab; 32]).unwrap(); + let mut builder = InvoiceBuilder::new(currency) + .payment_hash(sha256::Hash::from_byte_array([1u8; 32])) + .payment_secret(PaymentSecret([2u8; 32])) + .duration_since_epoch(Duration::from_secs(NOW - 10)) + .expiry_time(Duration::from_secs(expiry_secs)) + .min_final_cltv_expiry_delta(144) + .description("paykit".into()); + if let Some(msat) = amount_msat { + builder = builder.amount_milli_satoshis(msat); + } + builder + .build_signed(|hash| secp.sign_ecdsa_recoverable(hash, &key)) + .unwrap() + .to_string() + } + + /// An endpoint the contact published; a BOLT11 endpoint holds a payable regtest invoice. fn endpoint(method_id: &str) -> PaykitEndpoint { + let value = match method_id { + "btc-lightning-bolt11" => bolt11(Currency::Regtest, Some(20_000_000), 3_600), + _ => "v".into(), + }; PaykitEndpoint { method_id: method_id.into(), - value: "v".into(), + value, } } @@ -220,6 +288,67 @@ mod tests { )); } + #[test] + fn skips_bolt11_invoices_it_cannot_pay_now() { + let unpayable = [ + bolt11(Currency::Bitcoin, Some(20_000_000), 3_600), + bolt11(Currency::Regtest, Some(20_000_000), 5), + bolt11(Currency::Regtest, Some(19_000_000), 3_600), + "not an invoice".into(), + ]; + for value in unpayable { + let mut request = request(&["btc-regtest-p2wpkh"], 20_000, 20_000, vec![]); + request.contact_endpoints.push(PaykitEndpoint { + method_id: "btc-lightning-bolt11".into(), + value, + }); + assert_eq!(route(request), direct("btc-regtest-p2wpkh")); + } + let mut amountless = request(&[], 20_000, 0, vec![]); + amountless.contact_endpoints.push(PaykitEndpoint { + method_id: "btc-lightning-bolt11".into(), + value: bolt11(Currency::Regtest, None, 3_600), + }); + assert!(matches!(route(amountless), PaykitRoute::Direct { .. })); + let mut empty_lnurl = request(&[], 20_000, 0, vec![]); + empty_lnurl.contact_endpoints.push(PaykitEndpoint { + method_id: "btc-lightning-lnurl".into(), + value: " ".into(), + }); + assert!(matches!(route(empty_lnurl), PaykitRoute::None { .. })); + } + + #[test] + fn ignores_legs_to_an_address_the_contact_did_not_publish() { + let mut other = leg("btc-regtest-p2wpkh", 20_000, 20_001, 0); + other.endpoint.value = "other".into(); + assert!(matches!( + route(request(&["btc-regtest-p2wpkh"], 0, 0, vec![other])), + PaykitRoute::None { .. } + )); + } + + #[test] + fn pays_on_chain_endpoints_of_each_network_rail() { + let rails = [ + (bitcoin::Network::Bitcoin, "btc-bitcoin-p2tr"), + (bitcoin::Network::Testnet, "btc-testnet-p2tr"), + (bitcoin::Network::Signet, "btc-signet-p2tr"), + (bitcoin::Network::Regtest, "btc-regtest-p2tr"), + ]; + let published: Vec<&str> = rails.iter().map(|(_, id)| *id).collect(); + for (network, id) in rails { + let request = request(&published, 0, 20_000, vec![]); + assert_eq!(route_payment(&request, network, NOW).unwrap(), direct(id)); + } + let legs = vec![leg("btc-testnet-p2tr", 20_000, 20_001, 0)]; + let request = request(&published, 0, 20_000, legs); + assert!(matches!( + route_payment(&request, bitcoin::Network::Testnet4, NOW).unwrap(), + PaykitRoute::None { .. } + )); + } + #[test] fn explains_when_nothing_reaches_the_contact() { assert_eq!( diff --git a/src/modules/paykit_route/types.rs b/src/modules/paykit_route/types.rs index 06d0b3d2..1cf062fa 100644 --- a/src/modules/paykit_route/types.rs +++ b/src/modules/paykit_route/types.rs @@ -13,7 +13,8 @@ pub struct PaykitEndpoint { #[derive(Debug, Clone, uniffi::Record)] pub struct PaykitRouteRequest { pub amount_sat: u64, - /// The contact's current Payment Endpoints, as Paykit resolved them. + /// All of the contact's current Payment Endpoints, as Paykit resolved them. Pass the full list: + /// the router picks the endpoint, checking BOLT11 invoices for network, expiry and amount. pub contact_endpoints: Vec, /// What the wallet can pay over Lightning, its Spark balance included, net of the fees a /// payment of `amount_sat` costs. @@ -50,6 +51,7 @@ pub enum PaykitRoute { Direct { endpoint: PaykitEndpoint }, /// A service the wallet holds a balance on delivers to the contact. The app runs the leg. Service { leg: ServiceRouteLeg }, - /// Nothing can pay this contact. `reason` says why, for the user. + /// Nothing can pay this contact. `reason` is English detail for logs; the app shows its own + /// localized text. None { reason: String }, } From b2443d8ebd35addeaa7af78e1ac030575a5bdbd2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 23:14:51 +0100 Subject: [PATCH 08/12] ci: run paykit route tests --- .github/workflows/paykit-route-tests.yml | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/paykit-route-tests.yml diff --git a/.github/workflows/paykit-route-tests.yml b/.github/workflows/paykit-route-tests.yml new file mode 100644 index 00000000..ae185381 --- /dev/null +++ b/.github/workflows/paykit-route-tests.yml @@ -0,0 +1,29 @@ +name: Paykit Route Tests + +on: + pull_request: + push: + branches: [master, main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + paykit-route-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev pkg-config protobuf-compiler + - name: Run paykit route tests + run: cargo test --locked --lib modules::paykit_route From ac704166d07935e393f3bcf363959b83f0ed3445 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 23:22:44 +0100 Subject: [PATCH 09/12] chore: prepare 0.8.0-spark.3 prerelease --- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 ++-- bindings/android/gradle.properties | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64bb4770..c3f26230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-spark.2" +version = "0.8.0-spark.3" dependencies = [ "aes-gcm", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 33d6e728..721203ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-spark.2" +version = "0.8.0-spark.3" edition = "2021" rust-version = "1.90" diff --git a/Package.swift b/Package.swift index a5906140..1c982e20 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.8.0-spark.2" -let checksum = "78944dc3f2be4a99b85da634ef533846a03d5f5b2f9c64aede3b2cef07ce1815" +let tag = "v0.8.0-spark.3" +let checksum = "368dec88d50abc60e90093e59fb515a1f8e121d74279594860ed012a4ea303e0" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index b19426ac..ab73460f 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.8.0-spark.2 +version=0.8.0-spark.3 From 7f779a71267bbda8f7d091827fd87f85211b40a4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 09:45:41 +0100 Subject: [PATCH 10/12] fix: route no lightning on testnet4 --- src/modules/paykit_route/router.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/modules/paykit_route/router.rs b/src/modules/paykit_route/router.rs index 96e4c7d6..f40e9446 100644 --- a/src/modules/paykit_route/router.rs +++ b/src/modules/paykit_route/router.rs @@ -59,16 +59,32 @@ fn lightning_payable( if endpoint.method_id != "btc-lightning-bolt11" { return !endpoint.value.trim().is_empty(); } + let Some(currency) = invoice_currency(network) else { + return false; + }; let Ok(invoice) = Bolt11Invoice::from_str(endpoint.value.trim()) else { return false; }; - invoice.currency() == Currency::from(network) + invoice.currency() == currency && !invoice.would_expire(Duration::from_secs(now_unix)) && invoice .amount_milli_satoshis() .is_none_or(|msat| msat == amount_sat.saturating_mul(1000)) } +/// The invoice currency of the wallet's network, spelled out: `Currency::from` has no Testnet4 +/// arm, so it panics in debug builds and reads Testnet4 as Regtest in release. Like on chain, +/// Testnet4 has no rail, so it routes no Lightning. +fn invoice_currency(network: bitcoin::Network) -> Option { + match network { + bitcoin::Network::Bitcoin => Some(Currency::Bitcoin), + bitcoin::Network::Testnet => Some(Currency::BitcoinTestnet), + bitcoin::Network::Signet => Some(Currency::Signet), + bitcoin::Network::Regtest => Some(Currency::Regtest), + _ => None, + } +} + fn lightning_endpoint( endpoints: &[PaykitEndpoint], amount_sat: u64, @@ -341,8 +357,15 @@ mod tests { let request = request(&published, 0, 20_000, vec![]); assert_eq!(route_payment(&request, network, NOW).unwrap(), direct(id)); } + // Testnet4 has no rail: no on-chain endpoint, leg or invoice of any other network is paid. let legs = vec![leg("btc-testnet-p2tr", 20_000, 20_001, 0)]; - let request = request(&published, 0, 20_000, legs); + let mut request = request(&published, 20_000, 20_000, legs); + for currency in [Currency::Regtest, Currency::BitcoinTestnet] { + request.contact_endpoints.push(PaykitEndpoint { + method_id: "btc-lightning-bolt11".into(), + value: bolt11(currency, None, 3_600), + }); + } assert!(matches!( route_payment(&request, bitcoin::Network::Testnet4, NOW).unwrap(), PaykitRoute::None { .. } From 3b0ed79d6ba02883672c73655ff98409d42e99f3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 10:06:12 +0100 Subject: [PATCH 11/12] fix: route no lnurl on testnet4 --- src/modules/paykit_route/router.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/modules/paykit_route/router.rs b/src/modules/paykit_route/router.rs index f40e9446..b0fb6cd0 100644 --- a/src/modules/paykit_route/router.rs +++ b/src/modules/paykit_route/router.rs @@ -49,19 +49,20 @@ fn preferred( /// Whether a Lightning endpoint's value can pay `amount_sat` now. A BOLT11 invoice must be for /// the wallet's network, unexpired at `now_unix` and amountless or for exactly the amount. An -/// LNURL is only checked for a value, since checking it needs a network call. +/// LNURL is only checked for a value, since checking it needs a network call. A network with no +/// invoice currency pays neither. fn lightning_payable( endpoint: &PaykitEndpoint, amount_sat: u64, network: bitcoin::Network, now_unix: u64, ) -> bool { - if endpoint.method_id != "btc-lightning-bolt11" { - return !endpoint.value.trim().is_empty(); - } let Some(currency) = invoice_currency(network) else { return false; }; + if endpoint.method_id != "btc-lightning-bolt11" { + return !endpoint.value.trim().is_empty(); + } let Ok(invoice) = Bolt11Invoice::from_str(endpoint.value.trim()) else { return false; }; @@ -359,6 +360,8 @@ mod tests { } // Testnet4 has no rail: no on-chain endpoint, leg or invoice of any other network is paid. let legs = vec![leg("btc-testnet-p2tr", 20_000, 20_001, 0)]; + let mut published = published; + published.push("btc-lightning-lnurl"); let mut request = request(&published, 20_000, 20_000, legs); for currency in [Currency::Regtest, Currency::BitcoinTestnet] { request.contact_endpoints.push(PaykitEndpoint { From b563bab00c64516d7b3a65f545f5da74b88a7cf7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 10:14:36 +0100 Subject: [PATCH 12/12] docs: testnet4 routes nothing --- src/modules/paykit_route/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/paykit_route/README.md b/src/modules/paykit_route/README.md index 4839a9ce..51ffd3c2 100644 --- a/src/modules/paykit_route/README.md +++ b/src/modules/paykit_route/README.md @@ -26,4 +26,5 @@ screen shows it. The rail segment of an on-chain Payment Endpoint Identifier names the network the apps publish for (`bitcoin`, `testnet`, `signet`, `regtest`), so a contact's endpoint for another network is never -paid. The spec has no Testnet4 rail, so a Testnet4 wallet routes nothing on chain. +paid. The spec has no Testnet4 rail and BOLT11 has no Testnet4 currency, so a Testnet4 wallet +routes nothing: no Lightning endpoint, on-chain endpoint or service leg.