diff --git a/Cargo.lock b/Cargo.lock index f2d9384..8111228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1041,7 +1041,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-swaps.2" +version = "0.8.0-swaps.4" dependencies = [ "alloy-primitives", "alloy-rlp", diff --git a/Cargo.toml b/Cargo.toml index 0effee8..4f152a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-swaps.2" +version = "0.8.0-swaps.4" edition = "2021" rust-version = "1.91" diff --git a/Package.swift b/Package.swift index d56a144..abd38bd 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.8.0-swaps.2" -let checksum = "69c085c480bab97cff77dbc4a654762a469d72598107937dcc0c59c73e6c4680" +let tag = "v0.8.0-swaps.4" +let checksum = "3ec9de280a2483ad059c3eaa366dfe639b1234d3335d6546f82c2f5176f82ed1" 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 6572396..6063013 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-swaps.2 +version=0.8.0-swaps.4 diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index c0f5b65..17aec03 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -11652,6 +11652,11 @@ public struct PaykitRouteRequest { * Fee rate for the wallet's own claim transaction. */ public var feeRateSatVb: UInt64 + /** + * Routes through services the wallet holds a balance on (Spark), each already quoted by the + * app's engine for that service. They compete with the Pubky Swaps providers on fee. + */ + public var serviceLegs: [ServiceRouteLeg] // Default memberwise initializers are never public by default, so we // declare one manually. @@ -11665,13 +11670,18 @@ public struct PaykitRouteRequest { */providers: [String], /** * Fee rate for the wallet's own claim transaction. - */feeRateSatVb: UInt64) { + */feeRateSatVb: UInt64, + /** + * Routes through services the wallet holds a balance on (Spark), each already quoted by the + * app's engine for that service. They compete with the Pubky Swaps providers on fee. + */serviceLegs: [ServiceRouteLeg]) { self.amountSat = amountSat self.contactEndpoints = contactEndpoints self.lightningSpendableSat = lightningSpendableSat self.onchainSpendableSat = onchainSpendableSat self.providers = providers self.feeRateSatVb = feeRateSatVb + self.serviceLegs = serviceLegs } } @@ -11700,6 +11710,9 @@ extension PaykitRouteRequest: Equatable, Hashable { if lhs.feeRateSatVb != rhs.feeRateSatVb { return false } + if lhs.serviceLegs != rhs.serviceLegs { + return false + } return true } @@ -11710,6 +11723,7 @@ extension PaykitRouteRequest: Equatable, Hashable { hasher.combine(onchainSpendableSat) hasher.combine(providers) hasher.combine(feeRateSatVb) + hasher.combine(serviceLegs) } } @@ -11729,7 +11743,8 @@ public struct FfiConverterTypePaykitRouteRequest: FfiConverterRustBuffer { lightningSpendableSat: FfiConverterUInt64.read(from: &buf), onchainSpendableSat: FfiConverterUInt64.read(from: &buf), providers: FfiConverterSequenceString.read(from: &buf), - feeRateSatVb: FfiConverterUInt64.read(from: &buf) + feeRateSatVb: FfiConverterUInt64.read(from: &buf), + serviceLegs: FfiConverterSequenceTypeServiceRouteLeg.read(from: &buf) ) } @@ -11740,6 +11755,7 @@ public struct FfiConverterTypePaykitRouteRequest: FfiConverterRustBuffer { FfiConverterUInt64.write(value.onchainSpendableSat, into: &buf) FfiConverterSequenceString.write(value.providers, into: &buf) FfiConverterUInt64.write(value.feeRateSatVb, into: &buf) + FfiConverterSequenceTypeServiceRouteLeg.write(value.serviceLegs, into: &buf) } } @@ -12808,6 +12824,152 @@ 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. Pubky Swaps + * legs are `hash`. + */ + 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. Pubky Swaps + * legs are `hash`. + */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. */ @@ -24077,6 +24239,11 @@ public enum PaykitRoute { */ case swap(quote: PubkySwapRouteQuote ) + /** + * 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. */ @@ -24105,7 +24272,10 @@ public struct FfiConverterTypePaykitRoute: FfiConverterRustBuffer { case 2: return .swap(quote: try FfiConverterTypePubkySwapRouteQuote.read(from: &buf) ) - case 3: return .none(reason: try FfiConverterString.read(from: &buf) + case 3: return .service(leg: try FfiConverterTypeServiceRouteLeg.read(from: &buf) + ) + + case 4: return .none(reason: try FfiConverterString.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -24126,8 +24296,13 @@ public struct FfiConverterTypePaykitRoute: FfiConverterRustBuffer { FfiConverterTypePubkySwapRouteQuote.write(quote, into: &buf) - case let .none(reason): + case let .service(leg): writeInt(&buf, Int32(3)) + FfiConverterTypeServiceRouteLeg.write(leg, into: &buf) + + + case let .none(reason): + writeInt(&buf, Int32(4)) FfiConverterString.write(reason, into: &buf) } @@ -29354,6 +29529,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 @@ -31971,8 +32171,9 @@ public func pubkySwapsRestore(providers: [String], electrumUrl: String, payoutAd } /** * Pick how to pay a Paykit contact. Returns `Direct` when a balance the wallet holds reaches - * one of the contact's endpoints, otherwise the cheapest firm quote from the trusted Pubky - * Swaps providers, or `None` with a reason. The user never picks the swap. + * one of the contact's endpoints, otherwise the cheaper of the request's service legs and the + * trusted Pubky Swaps providers' firm quotes, or `None` with a reason. The user never picks + * the route. * * Each candidate in `request.providers`, at most 30, is asked for its offer over iroh. iroh finds * a candidate's address through n0's discovery servers, so n0 sees the keys looked up from the @@ -33304,7 +33505,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_pubky_swaps_restore() != 21980) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_func_pubky_swaps_route_paykit_payment() != 51456) { + if (uniffi_bitkitcore_checksum_func_pubky_swaps_route_paykit_payment() != 45030) { return InitializationResult.apiChecksumMismatch } if (uniffi_bitkitcore_checksum_func_record_rbf_boost() != 15433) { diff --git a/src/lib.rs b/src/lib.rs index 5774dbf..d729cbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3449,8 +3449,9 @@ pub async fn pubky_swaps_provider_edges( } /// Pick how to pay a Paykit contact. Returns `Direct` when a balance the wallet holds reaches -/// one of the contact's endpoints, otherwise the cheapest firm quote from the trusted Pubky -/// Swaps providers, or `None` with a reason. The user never picks the swap. +/// one of the contact's endpoints, otherwise the cheaper of the request's service legs and the +/// trusted Pubky Swaps providers' firm quotes, or `None` with a reason. The user never picks +/// the route. /// /// Each candidate in `request.providers`, at most 30, is asked for its offer over iroh. iroh finds /// a candidate's address through n0's discovery servers, so n0 sees the keys looked up from the diff --git a/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md index ec788d8..83720eb 100644 --- a/src/modules/pubky_swaps/README.md +++ b/src/modules/pubky_swaps/README.md @@ -21,6 +21,23 @@ exactly the amount asked for (`exact_out`): the lock covers the wallet's own cla `pubky_swaps_route_paykit_payment` returns `Direct` when a balance reaches one of the contact's endpoints, so the app only swaps when it must. +## Service routes + +Services the wallet holds a balance on can also deliver to the contact: a Spark withdrawal pays an +on-chain address from Spending without a Pubky Swaps provider. The app quotes each such leg with +its own engine for the service (Spark's fee quote) and passes it in +`PaykitRouteRequest.service_legs`; the router never talks to Spark. + +When no balance reaches the contact directly, the router keeps the cheapest service leg that +delivers exactly the amount to an endpoint the contact published and has not expired, asks the +providers for a swap quote, and returns whichever costs less in fees: `Service` with the leg, which +the app runs, or `Swap`. A swap wins a tie, since a hash lock needs no one's trust; a service leg +states who it trusts in `atomicity` (`trusted` for Spark). + +Lightning through Spark needs no service leg: the app counts that balance as +Lightning-spendable, and `Direct` covers it. One route has one leg: a service paying a provider's +hold invoice is not routed yet. + Before the invoice reaches the app, the module checks the quote against the request (`validate_quote`), the contract against the quote and the chain tip (`validate_accept`), rebuilds the HTLC from its own keys (`verify_contract`), and binds the hold invoice to the quote and the diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 647e5f9..bcb40ac 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -188,29 +188,65 @@ pub async fn route_paykit_payment( }); } } - let Some(onchain) = onchain else { - return Ok(PaykitRoute::None { - reason: "No balance can reach an endpoint this contact accepts.".into(), - }); + let service = router::best_service_leg(&request.service_legs, endpoints, amount, now_unix()); + let swap = match &onchain { + Some(onchain) if !request.providers.is_empty() => { + match best_swap_route( + &request, + onchain.clone(), + network, + &mnemonic, + bip39_passphrase.as_deref(), + ) + .await + { + Ok(swap) => swap, + Err(e) if service.is_some() => { + log::warn!("pubky swaps: no swap route, paying through a service: {e}"); + None + } + Err(e) => return Err(e), + } + } + _ => None, }; - if request.providers.is_empty() { - return Ok(PaykitRoute::None { - reason: "No swap provider is set up.".into(), - }); - } + Ok( + router::cheaper_route(service, swap).unwrap_or_else(|| PaykitRoute::None { + reason: if onchain.is_none() { + "No balance can reach an endpoint this contact accepts." + } else if request.providers.is_empty() { + "No swap provider is set up." + } else if network == bitcoin::Network::Bitcoin { + "Swap routes are not available on mainnet yet." + } else { + "No swap provider can route this payment right now." + } + .into(), + }), + ) +} + +/// The cheapest firm quote from the trusted Pubky Swaps providers for delivering the request's +/// amount to `onchain`, cached for `commit_route`. +async fn best_swap_route( + request: &PaykitRouteRequest, + onchain: PaykitEndpoint, + network: bitcoin::Network, + mnemonic: &str, + bip39_passphrase: Option<&str>, +) -> Result, PubkySwapError> { // The claim is broadcast at one fee and only rebroadcast, never bumped: until it can be, a // claim stuck below the market could lose the whole debit, so mainnet takes no swap route. + // Service legs still route there. if network == bitcoin::Network::Bitcoin { - return Ok(PaykitRoute::None { - reason: "Swap routes are not available on mainnet yet.".into(), - }); + return Ok(None); } - + let amount = request.amount_sat; let destination = parse_address(&onchain.value, network)?.script_pubkey(); let claim_fee_sat = claim_fee(&destination, request.fee_rate_sat_vb)?; let lock_sat = amount + claim_fee_sat; - let secret = keys::identity_secret(&mnemonic, bip39_passphrase.as_deref())?; + let secret = keys::identity_secret(mnemonic, bip39_passphrase)?; let identity = identity_pubky(&secret); let client = DirectClient::bind(secret).await?; @@ -282,9 +318,7 @@ pub async fn route_paykit_payment( client.close().await; let Some((quote, provider)) = best else { - return Ok(PaykitRoute::None { - reason: "No swap provider can route this payment right now.".into(), - }); + return Ok(None); }; let route = PubkySwapRouteQuote { provider_pubky: provider, @@ -313,7 +347,7 @@ pub async fn route_paykit_payment( network, }, ); - Ok(PaykitRoute::Swap { quote: route }) + Ok(Some(route)) } fn is_pending(reply: &Result) -> bool { @@ -938,6 +972,75 @@ mod tests { refused() ]))); } + + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + fn onchain_contact() -> PaykitEndpoint { + PaykitEndpoint { + method_id: "btc-regtest-p2wpkh".into(), + value: "bcrt1qdp8zwu9xk08p3dvkz4qyavmfaadkr4lnvwfah2".into(), + } + } + + fn request(service_legs: Vec) -> PaykitRouteRequest { + PaykitRouteRequest { + amount_sat: 20_000, + contact_endpoints: vec![onchain_contact()], + lightning_spendable_sat: 0, + onchain_spendable_sat: 0, + providers: vec![], + fee_rate_sat_vb: 2, + service_legs, + } + } + + async fn route(request: PaykitRouteRequest) -> PaykitRoute { + route_paykit_payment(request, bitcoin::Network::Regtest, MNEMONIC.into(), None) + .await + .unwrap() + } + + #[tokio::test] + async fn routes_an_onchain_contact_through_a_service_without_providers() { + let leg = ServiceRouteLeg { + service: "spark".into(), + endpoint: onchain_contact(), + delivered_sat: 20_000, + debit_sat: 20_300, + atomicity: "trusted".into(), + valid_until_unix: 0, + }; + let PaykitRoute::Service { leg: picked } = route(request(vec![leg.clone()])).await else { + panic!("expected a service route"); + }; + assert_eq!(picked, leg); + } + + #[tokio::test] + async fn routes_through_a_service_on_mainnet_where_swaps_are_refused() { + let leg = ServiceRouteLeg { + service: "spark".into(), + endpoint: onchain_contact(), + delivered_sat: 20_000, + debit_sat: 20_300, + atomicity: "trusted".into(), + valid_until_unix: 0, + }; + let mut request = request(vec![leg.clone()]); + request.providers = vec!["pubky-provider".into()]; + let route = + route_paykit_payment(request, bitcoin::Network::Bitcoin, MNEMONIC.into(), None).await; + assert!(matches!(route, Ok(PaykitRoute::Service { leg: picked }) if picked == leg)); + } + + #[tokio::test] + async fn explains_why_nothing_routes_without_providers_or_services() { + let PaykitRoute::None { reason } = route(request(vec![])).await else { + panic!("expected no route"); + }; + assert_eq!(reason, "No swap provider is set up."); + } } #[cfg(test)] @@ -976,6 +1079,7 @@ mod live_tests { onchain_spendable_sat: 0, providers: std::mem::take(&mut providers), fee_rate_sat_vb: 2, + service_legs: vec![], }; let route = route_paykit_payment(request, bitcoin::Network::Regtest, MNEMONIC.into(), None) .await diff --git a/src/modules/pubky_swaps/router.rs b/src/modules/pubky_swaps/router.rs index ae575ce..20eabd2 100644 --- a/src/modules/pubky_swaps/router.rs +++ b/src/modules/pubky_swaps/router.rs @@ -10,7 +10,9 @@ use swap_common::messages::SwapOffer; use swap_common::SwapDirection; -use super::types::{PaykitEndpoint, SwapRouteEdge}; +use super::types::{ + PaykitEndpoint, PaykitRoute, PubkySwapRouteQuote, ServiceRouteLeg, SwapRouteEdge, +}; pub(crate) const LIGHTNING_BOLT11: &str = "btc-lightning-bolt11"; pub(crate) const LIGHTNING_LNURL: &str = "btc-lightning-lnurl"; @@ -110,6 +112,38 @@ pub(crate) fn rank_reverse_edges( usable } +/// The cheapest service leg that delivers exactly `amount_sat` to an endpoint the contact +/// published and whose quote still holds at `now_unix`. +pub(crate) 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() +} + +/// The route that costs the wallet less in fees. A hash-locked swap wins a tie: the wallet +/// trusts no one to complete it. +pub(crate) fn cheaper_route( + service: Option, + swap: Option, +) -> Option { + match (service, swap) { + (Some(leg), Some(quote)) if quote.total_fee_sat <= leg.debit_sat - leg.delivered_sat => { + Some(PaykitRoute::Swap { quote }) + } + (Some(leg), _) => Some(PaykitRoute::Service { leg }), + (None, Some(quote)) => Some(PaykitRoute::Swap { quote }), + (None, None) => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -183,6 +217,83 @@ mod tests { assert_eq!(indicative_fee(&e, 100_000), 100 + 250 + 500); } + fn leg( + service: &str, + method_id: &str, + delivered: u64, + debit: u64, + until: u64, + ) -> ServiceRouteLeg { + ServiceRouteLeg { + service: service.into(), + endpoint: endpoint(method_id), + delivered_sat: delivered, + debit_sat: debit, + atomicity: "trusted".into(), + valid_until_unix: until, + } + } + + #[test] + fn picks_the_cheapest_service_leg_to_a_published_endpoint() { + let endpoints = vec![endpoint("btc-regtest-p2wpkh")]; + let legs = vec![ + leg("spark", "btc-regtest-p2wpkh", 20_000, 20_900, 0), + leg("spark", "btc-regtest-p2wpkh", 20_000, 20_400, 0), + leg("unpublished", "btc-regtest-p2tr", 20_000, 20_001, 0), + ]; + let picked = best_service_leg(&legs, &endpoints, 20_000, 100).unwrap(); + assert_eq!(picked.debit_sat, 20_400); + } + + #[test] + fn drops_service_legs_that_are_expired_or_deliver_another_amount() { + let endpoints = vec![endpoint("btc-regtest-p2wpkh")]; + let legs = vec![ + leg("expired", "btc-regtest-p2wpkh", 20_000, 20_100, 100), + leg("short", "btc-regtest-p2wpkh", 19_000, 19_100, 0), + leg("negative-fee", "btc-regtest-p2wpkh", 20_000, 19_999, 0), + ]; + assert!(best_service_leg(&legs, &endpoints, 20_000, 100).is_none()); + let live = leg("live", "btc-regtest-p2wpkh", 20_000, 20_100, 101); + assert_eq!( + best_service_leg(std::slice::from_ref(&live), &endpoints, 20_000, 100), + Some(live) + ); + } + + fn swap_quote(total_fee_sat: u64) -> PubkySwapRouteQuote { + PubkySwapRouteQuote { + provider_pubky: "p".into(), + quote_id: "q".into(), + endpoint: endpoint("btc-regtest-p2wpkh"), + delivered_sat: 20_000, + lock_sat: 20_200, + debit_sat: 20_000 + total_fee_sat, + service_fee_sat: 0, + provider_onchain_fee_sat: 0, + claim_fee_sat: 200, + total_fee_sat, + atomicity: "hash".into(), + valid_until_unix: 0, + providers_quoted: 1, + } + } + + #[test] + fn picks_the_route_with_the_lower_fee_and_the_swap_on_a_tie() { + let service = leg("spark", "btc-regtest-p2wpkh", 20_000, 20_500, 0); + let pick = |swap_fee| cheaper_route(Some(service.clone()), Some(swap_quote(swap_fee))); + assert!(matches!(pick(499), Some(PaykitRoute::Swap { .. }))); + assert!(matches!(pick(500), Some(PaykitRoute::Swap { .. }))); + assert!(matches!(pick(501), Some(PaykitRoute::Service { .. }))); + assert!(matches!( + cheaper_route(Some(service), None), + Some(PaykitRoute::Service { .. }) + )); + assert!(cheaper_route(None, None).is_none()); + } + #[test] fn drops_edges_above_the_client_fee_cap_and_other_endpoints() { // 100 + 50 + 500 = 650 sats on 20,000 is 3.25%: under the 5% cap, over a 3% one. diff --git a/src/modules/pubky_swaps/types.rs b/src/modules/pubky_swaps/types.rs index fa75fea..ac37df1 100644 --- a/src/modules/pubky_swaps/types.rs +++ b/src/modules/pubky_swaps/types.rs @@ -37,6 +37,28 @@ pub struct PaykitRouteRequest { pub providers: Vec, /// Fee rate for the wallet's own claim transaction. pub fee_rate_sat_vb: u64, + /// Routes through services the wallet holds a balance on (Spark), each already quoted by the + /// app's engine for that service. They compete with the Pubky Swaps providers on fee. + 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. Pubky Swaps + /// legs are `hash`. + pub atomicity: String, + /// When the quote stops holding, or 0 when it does not expire. + pub valid_until_unix: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] @@ -52,6 +74,8 @@ pub enum PaykitRoute { Direct { endpoint: PaykitEndpoint }, /// The wallet pays over Lightning and a Pubky Swaps provider delivers on-chain. Swap { quote: PubkySwapRouteQuote }, + /// 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 }, }