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 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 d362767d..aff1fe98 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. - Upgrade `trezor-connect-rs` to 10.0.1. The THP handshake now accepts a paired state only with a verified stored credential. A paired state without one, or an unknown state, returns `TrezorError::ConnectionError` instead of continuing to session creation. A malformed completion frame is now an error too, and the callback transport retries it like other temporary failures. A stored credential with a low-order device key no longer verifies, so the device pairs again. - `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/Cargo.lock b/Cargo.lock index 70b9b44b..c3f26230 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.3" dependencies = [ "aes-gcm", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 236fba84..721203ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-rc11" +version = "0.8.0-spark.3" edition = "2021" rust-version = "1.90" diff --git a/Package.swift b/Package.swift index 6b2ce47f..1c982e20 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.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 82cf1255..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-rc11 +version=0.8.0-spark.3 diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 73267bf6..dbc969e9 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -11575,6 +11575,212 @@ 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 + /** + * 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] + /** + * 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 + * 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, + /** + * 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 + * 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. + */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 +12412,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 +24965,185 @@ 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` is English detail for logs; the app shows its own + * localized text. + */ + 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(errorDetails: 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( + errorDetails: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PaykitRouteError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InvalidRequest(errorDetails): + writeInt(&buf, Int32(1)) + FfiConverterString.write(errorDetails, 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 +30383,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 +30458,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 +32933,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 +34660,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 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..51ffd3c2 --- /dev/null +++ b/src/modules/paykit_route/README.md @@ -0,0 +1,30 @@ +# 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. 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 + 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` 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. 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. diff --git a/src/modules/paykit_route/errors.rs b/src/modules/paykit_route/errors.rs new file mode 100644 index 00000000..6875de82 --- /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: {error_details}")] + InvalidRequest { error_details: 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..b0fb6cd0 --- /dev/null +++ b/src/modules/paykit_route/router.rs @@ -0,0 +1,396 @@ +//! 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}; +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"]; + +/// 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. 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 => 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, +) -> Option<&PaykitEndpoint> { + method_ids + .into_iter() + .find_map(|id| endpoints.iter().find(|e| e.method_id == id)) +} + +/// 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. A network with no +/// invoice currency pays neither. +fn lightning_payable( + endpoint: &PaykitEndpoint, + amount_sat: u64, + network: bitcoin::Network, + now_unix: u64, +) -> bool { + 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; + }; + 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, + 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> { + preferred(endpoints, onchain_method_ids(network)) +} + +/// 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 onchain = onchain_method_ids(network); + legs.iter() + .filter(|leg| endpoints.contains(&leg.endpoint)) + .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) + .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 { + error_details: "amount must be positive".into(), + }); + } + let endpoints = &request.contact_endpoints; + let direct = [ + ( + lightning_endpoint(endpoints, amount, network, now_unix), + 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, network, 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::*; + 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, + } + } + + 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 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 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)); + } + // 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 { + method_id: "btc-lightning-bolt11".into(), + value: bolt11(currency, None, 3_600), + }); + } + assert!(matches!( + route_payment(&request, bitcoin::Network::Testnet4, NOW).unwrap(), + PaykitRoute::None { .. } + )); + } + + #[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..1cf062fa --- /dev/null +++ b/src/modules/paykit_route/types.rs @@ -0,0 +1,57 @@ +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, + /// 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. + 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. + 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` is English detail for logs; the app shows its own + /// localized text. + None { reason: String }, +}