Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1269,7 +1269,7 @@
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = exactVersion;
version = "0.8.0-rc11";
version = "0.8.0-swaps.3";
};
};
96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,19 @@ enum Env {
return electrumServerUrl(for: network)
}

/// Whether a Paykit payment may route through a Pubky Swaps provider when no balance reaches the contact's
/// endpoints. Regtest debug builds with `PUBKY_SWAPS_ROUTES=true` only.
static var isPubkySwapsRouteEnabled: Bool {
isDebug && network == .regtest && boolConfigValue("PUBKY_SWAPS_ROUTES")
}

/// Swap providers to ask besides the user's contacts, from `PUBKY_SWAPS_PROVIDERS` (comma-separated pubkys).
static var pubkySwapsProviders: [String] {
(configValue("PUBKY_SWAPS_PROVIDERS") ?? "").split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}

static var trezorBridgeEnabled: Bool {
(isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE")
}
Expand Down
37 changes: 37 additions & 0 deletions Bitkit/Managers/SparkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import Observation
@Observable @MainActor
final class SparkManager: ChannelFreeLightningRail {
static let shared = SparkManager()
nonisolated static let routeService = "spark"
private nonisolated static let routeAtomicity = "trusted"
private nonisolated(unsafe) static let exitStateChanged = PassthroughSubject<Void, Never>()
/// Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone.
nonisolated static var exitStateChangedPublisher: AnyPublisher<Void, Never> {
Expand Down Expand Up @@ -253,6 +255,41 @@ final class SparkManager: ChannelFreeLightningRail {
try await refresh()
}

/// A Paykit route that withdraws exactly `amountSats` to the contact's on-chain `endpoint` at medium speed, with
/// Spark's fee on top, or nil while Spark cannot send it. bitkit-core weighs it against the Pubky Swaps providers.
func withdrawalRouteLeg(endpoint: PaykitEndpoint, amountSats: UInt64) async throws -> ServiceRouteLeg? {
guard isReady, balanceSats >= amountSats else { return nil }
let prepared = try await prepareSend(paymentRequest: endpoint.value, amountSats: amountSats)
guard case let .bitcoinAddress(_, feeQuote) = prepared.paymentMethod else {
throw AppError(message: "Unexpected Spark payment method", debugMessage: "\(prepared.paymentMethod)")
}
let debitSats = amountSats + feeQuote.speedMedium.userFeeSat
guard !feeQuote.isEstimate, balanceSats >= debitSats else { return nil }
return ServiceRouteLeg(
service: Self.routeService,
endpoint: endpoint,
deliveredSat: amountSats,
debitSat: debitSats,
atomicity: Self.routeAtomicity,
validUntilUnix: feeQuote.expiresAt
)
}

/// Withdraws exactly `amountSats` to the on-chain `address` at medium speed and returns the withdrawal's txid.
func withdraw(to address: String, amountSats: UInt64) async throws -> String {
let prepared = try await prepareSend(paymentRequest: address, amountSats: amountSats)
let response = try await requireSdk().sendPayment(request: SendPaymentRequest(
prepareResponse: prepared,
options: .bitcoinAddress(confirmationSpeed: .medium)
))
Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager")
try await refresh()
if case let .withdraw(txId) = response.payment.details {
return txId
}
return response.payment.id
}

/// What a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB.
func exportExitState() async throws -> String {
try await requireSdk().exportUnilateralExitState().exitState
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@
"wallet__send_sync_long_title" = "مشاكل\n<accent>الاتصال</accent>";
"wallet__send_sync_long_description" = "يبدو أنك غير متصل. يرجى التحقق من اتصالك. سيحاول Bitkit إعادة الاتصال كل بضع ثوانٍ.";
"wallet__send_to" = "إلى";
"wallet__send_swap_route" = "المسار";
"wallet__recipient_contact" = "جهة الاتصال";
"wallet__recipient_invoice" = "لصق فاتورة";
"wallet__recipient_manual" = "إدخال يدوي";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,7 @@
"wallet__send_sync_long_title" = "Problemes de\n<accent>connexió</accent>";
"wallet__send_sync_long_description" = "Sembla que estàs desconnectat. Comprova la connexió. Bitkit intentarà tornar a connectar-se cada pocs segons.";
"wallet__send_to" = "A";
"wallet__send_swap_route" = "Ruta";
"wallet__recipient_contact" = "Contacte";
"wallet__recipient_invoice" = "Enganxa factura";
"wallet__recipient_manual" = "Introdueix manualment";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,7 @@
"wallet__send_sync_long_title" = "<accent>Problémy</accent>\ns připojením";
"wallet__send_sync_long_description" = "Zdá se, že jste odpojeni. Zkontrolujte prosím připojení. Bitkit se bude každých několik sekund pokoušet znovu připojit.";
"wallet__send_to" = "Komu";
"wallet__send_swap_route" = "Trasa";
"wallet__recipient_contact" = "Kontakt";
"wallet__recipient_invoice" = "Vložit fakturu";
"wallet__recipient_manual" = "Zadat ručně";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,7 @@
"wallet__send_sync_long_title" = "<accent>Verbindungs-</accent>\nprobleme";
"wallet__send_sync_long_description" = "Anscheinend bist du nicht verbunden. Bitte überprüfe deine Verbindung. Bitkit versucht alle paar Sekunden, sich erneut zu verbinden.";
"wallet__send_to" = "An";
"wallet__send_swap_route" = "Route";
"wallet__recipient_contact" = "Kontakt";
"wallet__recipient_invoice" = "Rechnung einfügen";
"wallet__recipient_manual" = "Manuell eingeben";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/el.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,7 @@
"wallet__send_sync_long_title" = "<accent>Προβλήματα</accent>\nσύνδεσης";
"wallet__send_sync_long_description" = "Φαίνεται ότι είστε αποσυνδεδεμένοι. Ελέγξτε τη σύνδεσή σας. Το Bitkit θα προσπαθεί να επανασυνδεθεί κάθε λίγα δευτερόλεπτα.";
"wallet__send_to" = "Προς";
"wallet__send_swap_route" = "Διαδρομή";
"wallet__recipient_contact" = "Επαφή";
"wallet__recipient_invoice" = "Επικόλληση Τιμολογίου";
"wallet__recipient_manual" = "Χειροκίνητη Εισαγωγή";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,7 @@
"wallet__send_sync_long_title" = "<accent>Connection</accent>\nissues";
"wallet__send_sync_long_description" = "It appears you’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds.";
"wallet__send_to" = "To";
"wallet__send_swap_route" = "Route";
"wallet__recipient_contact" = "Contact";
"wallet__recipient_invoice" = "Paste Invoice";
"wallet__recipient_manual" = "Enter Manually";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,7 @@
"wallet__send_sync_long_title" = "Problemas de\n<accent>conexión</accent>";
"wallet__send_sync_long_description" = "Parece que está desconectado. Revise su conexión. Bitkit intentará reconectarse cada pocos segundos.";
"wallet__send_to" = "Para";
"wallet__send_swap_route" = "Ruta";
"wallet__recipient_contact" = "Contacto";
"wallet__recipient_invoice" = "Pegar factura";
"wallet__recipient_manual" = "Introducir manualmente";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,7 @@
"wallet__send_sync_long_title" = "Problemas de\n<accent>conexión</accent>";
"wallet__send_sync_long_description" = "Parece que está desconectado. Compruebe su conexión. Bitkit intentará reconectarse cada pocos segundos.";
"wallet__send_to" = "Para";
"wallet__send_swap_route" = "Ruta";
"wallet__recipient_contact" = "Contactar";
"wallet__recipient_invoice" = "Pegar factura";
"wallet__recipient_manual" = "Introducir manualmente";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,7 @@
"wallet__send_sync_long_title" = "Problèmes de\n<accent>connexion</accent>";
"wallet__send_sync_long_description" = "Il semble que vous soyez déconnecté. Veuillez vérifier votre connexion. Bitkit tentera de se reconnecter toutes les quelques secondes.";
"wallet__send_to" = "Pour";
"wallet__send_swap_route" = "Itinéraire";
"wallet__recipient_contact" = "Contact";
"wallet__recipient_invoice" = "Coller la facture";
"wallet__recipient_manual" = "Entrer manuellement";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,7 @@
"wallet__send_sync_long_title" = "Problemi di\n<accent>connessione</accent>";
"wallet__send_sync_long_description" = "Sembra che tu sia disconnesso. Controlla la connessione. Bitkit proverà a riconnettersi ogni pochi secondi.";
"wallet__send_to" = "A";
"wallet__send_swap_route" = "Percorso";
"wallet__recipient_contact" = "Contatto";
"wallet__recipient_invoice" = "Incolla fattura";
"wallet__recipient_manual" = "Inserisci Manualmente";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/nl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,7 @@
"wallet__send_sync_long_title" = "<accent>Verbindings</accent>\nproblemen";
"wallet__send_sync_long_description" = "Het lijkt erop dat u niet verbonden bent. Controleer uw verbinding. Bitkit probeert om de paar seconden opnieuw verbinding te maken.";
"wallet__send_to" = "Naar";
"wallet__send_swap_route" = "Route";
"wallet__recipient_contact" = "Contact";
"wallet__recipient_invoice" = "Factuur Plakken";
"wallet__recipient_manual" = "Handmatig Invoeren";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/pl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,7 @@
"wallet__send_sync_long_title" = "<accent>Problemy</accent>\nz połączeniem";
"wallet__send_sync_long_description" = "Wygląda na to, że nie masz połączenia. Sprawdź połączenie. Bitkit będzie próbował połączyć się ponownie co kilka sekund.";
"wallet__send_to" = "Do";
"wallet__send_swap_route" = "Trasa";
"wallet__recipient_contact" = "Kontakt";
"wallet__recipient_invoice" = "Wklej fakturę";
"wallet__recipient_manual" = "Wprowadź ręcznie";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,7 @@
"wallet__send_sync_long_title" = "Problemas de\n<accent>conexão</accent>";
"wallet__send_sync_long_description" = "Parece que você está desconectado. Verifique sua conexão. A Bitkit tentará se reconectar a cada poucos segundos.";
"wallet__send_to" = "Para";
"wallet__send_swap_route" = "Rota";
"wallet__recipient_contact" = "Contato";
"wallet__recipient_invoice" = "Colar Invoice";
"wallet__recipient_manual" = "Inserir Manualmente";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/pt.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@
"wallet__send_sync_long_title" = "Problemas\n<accent>de ligação</accent>";
"wallet__send_sync_long_description" = "Parece que está desligado. Verifique a sua ligação. A Bitkit vai tentar voltar a ligar a cada poucos segundos.";
"wallet__send_to" = "Para";
"wallet__send_swap_route" = "Rota";
"wallet__recipient_contact" = "Contato";
"wallet__recipient_invoice" = "Colar Invoice";
"wallet__recipient_manual" = "Inserir Manualmente";
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,7 @@
"wallet__send_sync_long_title" = "<accent>Проблемы</accent>\nс подключением";
"wallet__send_sync_long_description" = "Похоже, вы отключены. Проверьте подключение. Bitkit будет пытаться переподключиться каждые несколько секунд.";
"wallet__send_to" = "Кому";
"wallet__send_swap_route" = "Маршрут";
"wallet__recipient_contact" = "Контакт";
"wallet__recipient_invoice" = "Вставить Инвойс";
"wallet__recipient_manual" = "Ввести Вручную";
Expand Down
128 changes: 128 additions & 0 deletions Bitkit/Services/PubkySwapsRouteService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import BitkitCore
import Foundation

/// A Paykit payment routed through a Pubky Swaps provider or a service the wallet holds a balance on: the endpoint,
/// the route once quoted (a provider's quote or a service leg), and the provider's name.
struct PaykitSwapRoute: Equatable {
let endpoint: PaykitEndpoint
var quote: PubkySwapRouteQuote?
var providerName: String?
var serviceLeg: ServiceRouteLeg?

var deliveredSat: UInt64? {
quote?.deliveredSat ?? serviceLeg?.deliveredSat
}

var totalFeeSat: UInt64? {
quote?.totalFeeSat ?? serviceLeg.map { $0.debitSat - $0.deliveredSat }
}
}

/// Routes a Paykit payment through a Pubky Swaps provider when no balance the wallet holds reaches an endpoint the
/// contact published. bitkit-core picks the provider and checks its quote, contract and hold invoice; the wallet pays
/// the hold invoice from spending and bitkit-core claims the provider's lockup straight to the contact's address.
enum PubkySwapsRouteService {
private static let claimFeeRateSatVb: UInt64 = 2
private static let pollInterval: Duration = .seconds(2)
private static let claimTimeout: TimeInterval = 10 * 60

static var isEnabled: Bool {
Env.isPubkySwapsRouteEnabled
}

/// A contact whose only endpoints are on-chain, paid when savings cannot cover it but spending can.
static func route(for context: ContactPaymentContext, address: String, onchainBalanceSats: UInt64,
lightningBalanceSats: UInt64) -> PaykitSwapRoute?
{
guard isEnabled, context.incomingPaymentRequest == nil, !address.isEmpty,
!context.endpoints.isEmpty, context.endpoints.allSatisfy({ $0.methodId.onchainNetwork != nil }),
onchainBalanceSats <= UInt64(Env.dustLimit), lightningBalanceSats > 0,
let methodId = context.endpoints.first?.methodId
else { return nil }
return PaykitSwapRoute(endpoint: PaykitEndpoint(methodId: methodId.rawValue, value: address))
}

static func quote(
amountSats: UInt64,
endpoint: PaykitEndpoint,
lightningSpendableSats: UInt64,
onchainSpendableSats: UInt64,
contacts: [String]
) async throws -> PaykitRoute {
// Providers come from the user's own graph: every contact is asked for its swap offer.
let candidates = contacts + Env.pubkySwapsProviders
let serviceLegs = await serviceLegs(endpoint: endpoint, amountSats: amountSats)
Logger.debug("Routing Paykit payment via \(candidates.map(PubkyPublicKeyFormat.redacted))", context: "PubkySwapsRoute")
let (mnemonic, passphrase) = try secrets()
return try await pubkySwapsRoutePaykitPayment(
request: PaykitRouteRequest(
amountSat: amountSats,
contactEndpoints: [endpoint],
lightningSpendableSat: lightningSpendableSats,
onchainSpendableSat: onchainSpendableSats,
providers: candidates,
feeRateSatVb: claimFeeRateSatVb,
serviceLegs: serviceLegs
),
network: Env.bitkitCoreNetwork,
mnemonic: mnemonic,
bip39Passphrase: passphrase
)
}

/// Legs through the services the wallet holds a balance on, which compete with the providers on fee.
private static func serviceLegs(endpoint: PaykitEndpoint, amountSats: UInt64) async -> [ServiceRouteLeg] {
do {
return try await SparkManager.shared.withdrawalRouteLeg(endpoint: endpoint, amountSats: amountSats).map { [$0] } ?? []
} catch {
Logger.warn("No Spark route to \(endpoint.methodId): \(error)", context: "PubkySwapsRoute")
return []
}
}

/// Runs a service leg the router picked and returns the txid that pays the contact.
static func pay(_ leg: ServiceRouteLeg) async throws -> String {
guard leg.service == SparkManager.routeService else {
throw AppError(message: "Unsupported route service", debugMessage: leg.service)
}
return try await SparkManager.shared.withdraw(to: leg.endpoint.value, amountSats: leg.deliveredSat)
}

static func commit(quoteId: String) async throws -> PubkySwapRoutePayment {
let (mnemonic, passphrase) = try secrets()
return try await pubkySwapsCommitRoute(
quoteId: quoteId,
electrumUrl: Env.electrumServerUrl,
mnemonic: mnemonic,
bip39Passphrase: passphrase
)
}

/// Advance the swap until bitkit-core claims the lockup to the contact, returning the claim txid.
static func awaitClaim(swapId: String) async throws -> String {
let deadline = Date().addingTimeInterval(claimTimeout)
while true {
let (mnemonic, passphrase) = try secrets()
let status = try await pubkySwapsAdvance(swapId: swapId, mnemonic: mnemonic, bip39Passphrase: passphrase)
switch status.state {
case let .claimed(txid):
return txid
case let .failed(reason):
throw AppError(message: reason, debugMessage: nil)
default:
Logger.debug("Pubky Swaps route \(swapId): \(status.state)", context: "PubkySwapsRoute")
}
guard Date() < deadline else {
throw AppError(message: "Swap \(swapId) not claimed yet", debugMessage: "\(status.state)")
}
try await Task.sleep(for: pollInterval)
}
}

private static func secrets() throws -> (String, String?) {
guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else {
throw AppError(message: "Mnemonic not found", debugMessage: nil)
}
return (mnemonic, try? Keychain.loadString(key: .bip39Passphrase(index: 0)))
}
}
14 changes: 14 additions & 0 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ class AppViewModel: ObservableObject {
@Published var contactPaymentContext: ContactPaymentContext?
private(set) var didRejectScannedPayment = false
@Published var paykitUsesUsdt = false
@Published var paykitSwapRoute: PaykitSwapRoute?
@Published var paykitReviewedAmount: PaykitAmount?
@Published var paykitPaymentTerms: PaykitRequestPricing.Payment?

Expand Down Expand Up @@ -1019,6 +1020,18 @@ extension AppViewModel {
// Fallback to on-chain if address is available
guard !invoice.address.isEmpty, validateIncomingOnchainPaymentRequestAmount(requestedAmount ?? invoice.amountSatoshis) else { return }

if let context = contactPaymentContext, let route = PubkySwapsRouteService.route(
for: context,
address: invoice.address,
onchainBalanceSats: max(paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats),
lightningBalanceSats: paymentState.totalLightningBalanceSats
) {
handleScannedOnchainInvoice(invoice)
paykitSwapRoute = route
selectedWalletToPayFrom = .lightning
return
}

// If node is running, validate balance immediately
if paymentState.isNodeRunning {
let onchainBalance = max(
Expand Down Expand Up @@ -1364,6 +1377,7 @@ extension AppViewModel {
sendStateOwnerID = ownerID
sendStateGeneration = UUID()
paykitUsesUsdt = false
paykitSwapRoute = nil
paykitReviewedAmount = nil
paykitPaymentTerms = nil
scannedLightningInvoice = nil
Expand Down
Loading
Loading