From 9ad43d0eac37203ad5971ef30ae3d9b8ebe459ab Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 14 Nov 2024 09:55:26 -0300 Subject: [PATCH] BLIP 42 support --- buildSrc/src/main/kotlin/Versions.kt | 2 +- phoenix-ios/phoenix-ios/Localizable.xcstrings | 3 + .../kotlin/KotlinExtensions+Payments.swift | 28 +++-- .../views/contacts/AddToContactsInfo.swift | 1 + .../views/contacts/ManageContact.swift | 100 +++++++++++++++++- .../views/receive/LightningDualView.swift | 7 +- .../phoenix-ios/views/send/ValidateView.swift | 51 ++++++++- .../appdb/fr.acinq.phoenix.db/Contacts.sq | 36 +++++++ .../fr.acinq.phoenix.db/migrations/8.sqm | 17 +++ .../fr.acinq.phoenix/data/ContactInfo.kt | 27 ++--- .../fr.acinq.phoenix/data/DefaultOffer.kt | 32 ------ .../db/cloud/contacts/CloudContact.kt | 38 ++++++- .../db/notifications/ContactQueries.kt | 92 +++++++++++++++- .../managers/ContactsManager.kt | 42 ++++---- .../managers/NodeParamsManager.kt | 14 +-- .../fr.acinq.phoenix/managers/SendManager.kt | 3 + .../fr.acinq.phoenix/utils/CsvWriter.kt | 5 +- .../utils/extensions/PaymentExtensions.kt | 4 +- .../extensions/PaymentRequestExtensions.kt | 1 + .../acinq/phoenix/utils/LightningExposure.kt | 11 ++ 20 files changed, 405 insertions(+), 109 deletions(-) create mode 100644 phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/8.sqm delete mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/DefaultOffer.kt diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index be1bfafbe..f5bf8cffd 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,5 +1,5 @@ object Versions { - const val lightningKmp = "1.8.4" + const val lightningKmp = "1.8.5-SNAPSHOT" const val secp256k1 = "0.14.0" const val kotlin = "1.9.22" diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 741542c39..173235d54 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -34795,6 +34795,9 @@ } } } + }, + "Secrets: (DEBUG build only)" : { + }, "Security" : { "extractionState" : "manual", diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index 57f8de446..89a391025 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -189,7 +189,7 @@ extension WalletPaymentInfo { var msg: String? = nil if let incomingOfferMetadata = payment.incomingOfferMetadata() { - msg = incomingOfferMetadata.payerNote + msg = incomingOfferMetadata.payerNote_ } else if let outgoingInvoiceRequest = payment.outgoingInvoiceRequest() { msg = outgoingInvoiceRequest.payerNote @@ -209,7 +209,24 @@ extension WalletPaymentInfo { func addToContactsInfo() -> AddToContactsInfo? { - if payment is Lightning_kmpOutgoingPayment { + if let incoming = payment as? Lightning_kmpIncomingPayment { + + if let metadata = payment.incomingOfferMetadataV2() { + if let rawSecret = metadata.contactSecret { + let offer = metadata.payerOffer + let address = metadata.payerAddress?.description() + if (offer != nil) || (address != nil) { + let secret = ContactSecret( + id: rawSecret, + incomingPaymentId: incoming.paymentHash, + createdAt: Date.now.toInstant() + ) + return AddToContactsInfo(offer: offer, address: address, secret: secret) + } + } + } + + } else if payment is Lightning_kmpOutgoingPayment { // First check for a lightning address. // Remember that an outgoing payment might have both an address & offer (i.e. BIP-353). @@ -221,12 +238,11 @@ extension WalletPaymentInfo { // But that's a different feature. The user's perspective remains the same. // if let address = self.metadata.lightningAddress { - return AddToContactsInfo(offer: nil, address: address) + return AddToContactsInfo(offer: nil, address: address, secret: nil) } - let invoiceRequest = payment.outgoingInvoiceRequest() - if let offer = invoiceRequest?.offer { - return AddToContactsInfo(offer: offer, address: nil) + if let offer = payment.outgoingInvoiceRequest()?.offer { + return AddToContactsInfo(offer: offer, address: nil, secret: nil) } } diff --git a/phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift b/phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift index bbf39dfa6..9e045ed4e 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/AddToContactsInfo.swift @@ -4,4 +4,5 @@ import PhoenixShared struct AddToContactsInfo: Hashable { let offer: Lightning_kmpOfferTypesOffer? let address: String? + let secret: ContactSecret? } diff --git a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift index 65dc3da3b..fb98570f7 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift @@ -98,6 +98,9 @@ struct ManageContact: View { @State private var editAddress_text: String = "" @State private var editAddress_invalidReason: InvalidReason? = nil + @State private var secrets: [ContactSecret] + @State private var secrets_hasChanges: Bool + enum FooterType: Int { case expanded_standard = 1 case expanded_squeezed = 2 @@ -180,7 +183,6 @@ struct ManageContact: View { hasNewOffer = true } } - self._offers = State(initialValue: rows) self._offers_hasChanges = State(initialValue: (contact != nil && hasNewOffer)) @@ -206,11 +208,34 @@ struct ManageContact: View { hasNewAddress = true } } - self._addresses = State(initialValue: rows) self._addresses_hasChanges = State(initialValue: (contact != nil && hasNewAddress)) } + do { + var set = Set() + var secrets = Array() + + if let contact { + for secret in contact.secrets { + if !set.contains(secret.id) { + set.insert(secret.id) + secrets.append(secret) + } + } + } + var hasNewSecret = false + if let newSecret = info?.secret { + if !set.contains(newSecret.id) { + set.insert(newSecret.id) + secrets.append(newSecret) + hasNewSecret = true + } + } + + self._secrets = State(initialValue: secrets) + self._secrets_hasChanges = State(initialValue: (contact != nil && hasNewSecret)) + } } // -------------------------------------------------- @@ -389,6 +414,9 @@ struct ManageContact: View { content_trusted() content_offers() content_addresses() + #if DEBUG + content_secrets() + #endif } // .padding() } // @@ -927,6 +955,67 @@ struct ManageContact: View { .padding(.top, ROW_VERTICAL_SPACING) } + @ViewBuilder + func content_secrets() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Secrets: (DEBUG build only)") + Spacer(minLength: 0) + } // + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + ForEach(0 ..< secrets.count, id: \.self) { idx in + content_secret_row(idx) + } // + } // + + if secrets.isEmpty { + content_secret_emptyRow() + } + + } // + .padding(.bottom, 30) + } + + @ViewBuilder + func content_secret_row(_ index: Int) -> some View { + + let row: ContactSecret = secrets[index] + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + bullet() + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text(row.id.toHex()) + .foregroundStyle(Color.primary) + Text(verbatim: "incomingPaymentId: \( row.incomingPaymentId?.toHex() ?? "" )") + .foregroundStyle(Color.secondary) + } + .lineLimit(1) + .truncationMode(.middle) + .font(.callout) + + } + .padding(.top, ROW_VERTICAL_SPACING) + } + + @ViewBuilder + func content_secret_emptyRow() -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("none") + .lineLimit(1) + .foregroundStyle(Color.secondary) + .layoutPriority(-1) + .font(.callout) + } + .padding(.top, ROW_VERTICAL_SPACING) + } + @ViewBuilder func bullet() -> some View { @@ -1283,7 +1372,7 @@ struct ManageContact: View { if doNotUseDiskImage { return true } - if offers_hasChanges || addresses_hasChanges { + if offers_hasChanges || addresses_hasChanges || secrets_hasChanges { return true } @@ -1480,9 +1569,12 @@ struct ManageContact: View { photoUri: newPhotoName, useOfferKey: updatedUseOfferKey, offers: offers.map { $0.raw }, - addresses: addresses.map { $0.raw } + addresses: addresses.map { $0.raw }, + secrets: secrets ) + log.debug("updatedContact.secrets.count: \(updatedContact.secrets.count)") + try await Biz.business.contactsManager.saveContact(contact: updatedContact) if let oldPhotoName, oldPhotoName != newPhotoName { diff --git a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift index 06c3f202d..8ac6a6a59 100644 --- a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift @@ -727,8 +727,11 @@ struct LightningDualView: View { func generateQrCode() async { do { - let offerData = try await Biz.business.nodeParamsManager.defaultOffer() - let offerString = offerData.defaultOffer.encode() + let offerAndKey: Lightning_kmpOfferTypesOfferAndKey = + try await Biz.business.nodeParamsManager.defaultOffer() + + let offer: Lightning_kmpOfferTypesOffer = offerAndKey.offer + let offerString: String = offer.encode() offerStr = offerString if activeType == .bolt12_offer { diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 2c2dc4e8c..dc6450513 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -1635,7 +1635,7 @@ struct ValidateView: View { return } - let info = AddToContactsInfo(offer: offer, address: address) + let info = AddToContactsInfo(offer: offer, address: address, secret: nil) let count: Int = Biz.business.contactsManager.contactsListCurrentValue().count if count == 0 { @@ -1821,11 +1821,53 @@ struct ValidateView: View { Biz.beginLongLivedTask(id: paymentId.description()) let payerKey: Bitcoin_kmpPrivateKey - if contact?.useOfferKey ?? false { - let offerData = try await Biz.business.nodeParamsManager.defaultOffer() - payerKey = offerData.payerKey + let contactSecret: Bitcoin_kmpByteVector32? + + if let contact, contact.useOfferKey { + let offerAndKey: Lightning_kmpOfferTypesOfferAndKey = + try await Biz.business.nodeParamsManager.defaultOffer() + + payerKey = offerAndKey.privateKey + + if let existingSecret = contact.secrets.first { + // We already have a known secret with this contact. + // This could be because: + // A) we added the contact from an incoming payment which contained a secet + // B) we've already sent them a payment, and generated the secret in the past + contactSecret = existingSecret.id + + } else { + // Generate a new secret using the recommended derivation algorithm. + let rawSecret: Lightning_kmpContactSecrets = + LightningExposureKt.Contacts_computeContactSecret( + ourOffer: offerAndKey, + theirOffer: model.offer + ) + + // Store the new secret to the database + let newSecret = ContactSecret( + id: rawSecret.primarySecret, + incomingPaymentId: nil, + createdAt: Date.now.toInstant() + ) + let updatedContact = contact.doCopy( + id : contact.id, + name : contact.name, + photoUri : contact.photoUri, + useOfferKey : contact.useOfferKey, + offers : contact.offers, + addresses : contact.addresses, + secrets : contact.secrets + [newSecret] + ) + try await Biz.business.contactsManager.saveContact(contact: updatedContact) + + // Use the newly generated secret for this payment + contactSecret = newSecret.id + } + } else { payerKey = Lightning_randomKey() + contactSecret = nil } let response: Lightning_kmpOfferNotPaid? = @@ -1836,6 +1878,7 @@ struct ValidateView: View { lightningAddress: model.lightningAddress, payerKey: payerKey, payerNote: payerNote, + contactSecret: contactSecret, fetchInvoiceTimeoutInSeconds: 30 ) diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq index af36a8269..67dd9491e 100644 --- a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Contacts.sq @@ -29,9 +29,19 @@ CREATE TABLE IF NOT EXISTS contact_addresses ( FOREIGN KEY(contact_id) REFERENCES contacts(id) ); +CREATE TABLE IF NOT EXISTS contact_secrets ( + secret_id BLOB NOT NULL PRIMARY KEY, + contact_id TEXT NOT NULL, + incoming_payment_id BLOB, + created_at INTEGER NOT NULL, + + FOREIGN KEY(contact_id) REFERENCES contacts(id) +); + CREATE INDEX contact_name_index ON contacts(name ASC); CREATE INDEX contact_id_index ON contact_offers(contact_id); CREATE INDEX contact_id_index2 ON contact_addresses(contact_id); +CREATE INDEX contact_id_index3 ON contact_secrets(contact_id); -- ########## table: contacts ########## @@ -121,3 +131,29 @@ DELETE FROM contact_addresses WHERE address_hash=:addressHash; deleteContactAddressesForContactId: DELETE FROM contact_addresses WHERE contact_id=:contactId; + +-- ########## table: contact_secrets ########## + +listContactSecrets: +SELECT * +FROM contact_secrets; + +listSecretsForContact: +SELECT * +FROM contact_secrets +WHERE contact_id=:contactId; + +insertSecretForContact: +INSERT INTO contact_secrets(secret_id, contact_id, incoming_payment_id, created_at) +VALUES (:secretId, :contactId, :incomingPaymentId, :createdAt); + +updateContactSecret: +UPDATE contact_secrets +SET incoming_payment_id=:incomingPaymentId +WHERE secret_id=:secretId; + +deleteContactSecretForSecretId: +DELETE FROM contact_secrets WHERE secret_id=:secretId; + +deleteContactSecretsForContactId: +DELETE FROM contact_secrets WHERE contact_id=:contactId; diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/8.sqm b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/8.sqm new file mode 100644 index 000000000..686dec48d --- /dev/null +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/migrations/8.sqm @@ -0,0 +1,17 @@ +-- Migration: v8 -> v9 +-- +-- Changes: +-- * Added table contact_secrets +-- * Added index on table: contact_secrets +-- + +CREATE TABLE IF NOT EXISTS contact_secrets ( + secret_id BLOB NOT NULL PRIMARY KEY, + contact_id TEXT NOT NULL, + incoming_payment_id BLOB, + created_at INTEGER NOT NULL, + + FOREIGN KEY(contact_id) REFERENCES contacts(id) +); + +CREATE INDEX contact_id_index3 ON contact_secrets(contact_id); diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt index 5e4961afc..d8cc2e06e 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/ContactInfo.kt @@ -71,6 +71,12 @@ data class ContactAddress( } } +data class ContactSecret( + val id: ByteVector32, + val incomingPaymentId: ByteVector32?, + val createdAt: Instant +) + data class ContactInfo( val id: UUID, val name: String, @@ -78,22 +84,5 @@ data class ContactInfo( val useOfferKey: Boolean, val offers: List, val addresses: List, - val publicKeys: List, -) { - constructor( - id: UUID, - name: String, - photoUri: String?, - useOfferKey: Boolean, - offers: List, - addresses: List - ) : this( - id = id, - name = name, - photoUri = photoUri, - useOfferKey = useOfferKey, - offers = offers, - addresses = addresses, - publicKeys = offers.map { it.offer.contactNodeIds }.flatten() - ) -} + val secrets: List, +) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/DefaultOffer.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/DefaultOffer.kt deleted file mode 100644 index 7b09af6e7..000000000 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/DefaultOffer.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.lightning.wire.OfferTypes - -/** - * @param defaultOffer The default offer for a node. - * @param payerKey A private key attached to a node. It can be used to sign payments to offers of - * third parties and prove the origin of that payment. The recipient of that payment can then - * decide that this origin is trusted, and show/hide the `payerNote` attached to that payment. - * - */ -data class OfferData( - val defaultOffer: OfferTypes.Offer, - val payerKey: PrivateKey -) \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt index f431739bc..88d269eb4 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/contacts/CloudContact.kt @@ -4,11 +4,13 @@ import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.WalletPayment import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.data.ContactOffer +import fr.acinq.phoenix.data.ContactSecret import fr.acinq.phoenix.db.cloud.CloudData import fr.acinq.phoenix.db.cloud.CloudDataVersion import fr.acinq.phoenix.db.cloud.IncomingPaymentWrapper @@ -21,6 +23,7 @@ import kotlinx.datetime.Instant import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray @@ -62,7 +65,8 @@ data class CloudContact_V1( photoUri = photoUri, useOfferKey = this.useOfferKey, offers = mappedOffers, - addresses = listOf() + addresses = listOf(), + secrets = listOf() ) } @@ -78,7 +82,8 @@ data class CloudContact_V2( val name: String, val useOfferKey: Boolean, val offers: List, - val addresses: List + val addresses: List, + val secrets: List ) { constructor(contact: ContactInfo) : this( version = CloudContactVersion.V0.value, @@ -86,7 +91,8 @@ data class CloudContact_V2( name = contact.name, useOfferKey = contact.useOfferKey, offers = contact.offers.map { ContactOfferWrapper(it) }, - addresses = contact.addresses.map { ContactAddressWrapper(it) } + addresses = contact.addresses.map { ContactAddressWrapper(it) }, + secrets = contact.secrets.map { ContactSecretWrapper(it) } ) @Throws(Exception::class) @@ -97,7 +103,8 @@ data class CloudContact_V2( photoUri = photoUri, useOfferKey = this.useOfferKey, offers = this.offers.map { it.unwrap() }, - addresses = this.addresses.map { it.unwrap() } + addresses = this.addresses.map { it.unwrap() }, + secrets = this.secrets.map { it.unwrap() } ) } @@ -147,6 +154,29 @@ data class CloudContact_V2( ) } } + + @Serializable + @OptIn(ExperimentalSerializationApi::class) + data class ContactSecretWrapper( + @ByteString val secretId: ByteArray, + @ByteString val incomingPaymentId: ByteArray?, + val createdAt: Long + ) { + constructor(secret: ContactSecret) : this( + secretId = secret.id.toByteArray(), + incomingPaymentId = secret.incomingPaymentId?.toByteArray(), + createdAt = secret.createdAt.toEpochMilliseconds() + ) + + @Throws(Exception::class) + fun unwrap(): ContactSecret { + return ContactSecret( + id = this.secretId.toByteVector32(), + incomingPaymentId = this.incomingPaymentId?.toByteVector32(), + createdAt = Instant.fromEpochMilliseconds(this.createdAt) + ) + } + } } typealias CloudContact = CloudContact_V2 diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt index 09a9869c4..e5b6d5494 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/ContactQueries.kt @@ -22,15 +22,18 @@ import fr.acinq.bitcoin.byteVector32 import fr.acinq.bitcoin.utils.Try import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.utils.toByteVector32 import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.data.ContactOffer +import fr.acinq.phoenix.data.ContactSecret import fr.acinq.phoenix.db.AppDatabase import fr.acinq.phoenix.db.didDeleteContact import fr.acinq.phoenix.db.didSaveContact import fracinqphoenixdb.Contact_addresses import fracinqphoenixdb.Contact_offers +import fracinqphoenixdb.Contact_secrets import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -85,6 +88,14 @@ class ContactQueries(val database: AppDatabase) { createdAt = row.createdAt.toEpochMilliseconds() ) } + contact.secrets.forEach { row -> + queries.insertSecretForContact( + secretId = row.id.toByteArray(), + contactId = contact.id.toString(), + incomingPaymentId = row.incomingPaymentId?.toByteArray(), + createdAt = row.createdAt.toEpochMilliseconds() + ) + } } } @@ -176,6 +187,45 @@ class ContactQueries(val database: AppDatabase) { addressHash = key.toByteArray() ) } + + val existingSecrets: MutableMap = + queries.listSecretsForContact( + contactId = contact.id.toString() + ).executeAsList().mapNotNull { secretRow -> + parseSecretRow(secretRow)?.let { secret -> + secret.id to secret + } + }.toMap().toMutableMap() + contact.secrets.forEach { row -> + val result: ComparisonResult = + existingSecrets.remove(row.id)?.let { existing -> + compareSecrets(existing, row) + } ?: ComparisonResult.IsNew + when (result) { + ComparisonResult.IsNew -> { + queries.insertSecretForContact( + secretId = row.id.toByteArray(), + contactId = contact.id.toString(), + incomingPaymentId = row.incomingPaymentId?.toByteArray(), + createdAt = row.createdAt.toEpochMilliseconds() + ) + } + ComparisonResult.IsUpdated -> { + queries.updateContactSecret( + incomingPaymentId = row.incomingPaymentId?.toByteArray(), + secretId = row.id.toByteArray() + ) + } + ComparisonResult.NoChanges -> {} + } + } + // In the loop above we removed every matching secret. + // So any items leftover have been deleted from the contact. + existingSecrets.forEach { (key, _) -> + queries.deleteContactSecretForSecretId( + secretId = key.toByteArray() + ) + } } } @@ -194,13 +244,19 @@ class ContactQueries(val database: AppDatabase) { ).executeAsList().map { addressRow -> parseAddressRow(addressRow) } + val secrets: List = queries.listSecretsForContact( + contactId = contactId.toString() + ).executeAsList().map { secretRow -> + parseSecretRow(secretRow) + } ContactInfo( id = contactId, name = contactRow.name, photoUri = contactRow.photo_uri, useOfferKey = contactRow.use_offer_key, offers = offers, - addresses = addresses + addresses = addresses, + secrets = secrets ) } } @@ -229,6 +285,16 @@ class ContactQueries(val database: AppDatabase) { } } + val secrets: MutableMap> = mutableMapOf() + queries.listContactSecrets().executeAsList().forEach { secretRow -> + parseSecretRow(secretRow).let { secret -> + val contactId = UUID.fromString(secretRow.contact_id) + secrets[contactId]?.add(secret) ?: run { + secrets[contactId] = mutableListOf(secret) + } + } + } + queries.listContacts2().executeAsList().map { contactRow -> val contactId = UUID.fromString(contactRow.id) ContactInfo( @@ -237,7 +303,8 @@ class ContactQueries(val database: AppDatabase) { photoUri = contactRow.photo_uri, useOfferKey = contactRow.use_offer_key, offers = offers[contactId]?.toList() ?: listOf(), - addresses = addresses[contactId]?.toList() ?: listOf() + addresses = addresses[contactId]?.toList() ?: listOf(), + secrets = secrets[contactId]?.toList() ?: listOf() ) } } @@ -255,6 +322,7 @@ class ContactQueries(val database: AppDatabase) { database.transaction { queries.deleteContactOffersForContactId(contactId = contactId.toString()) queries.deleteContactAddressesForContactId(contactId = contactId.toString()) + queries.deleteContactSecretsForContactId(contactId = contactId.toString()) queries.deleteContact(contactId = contactId.toString()) didDeleteContact(contactId, database) } @@ -281,6 +349,15 @@ class ContactQueries(val database: AppDatabase) { ) } + private fun parseSecretRow(row: Contact_secrets): ContactSecret { + return ContactSecret( + id = row.secret_id.toByteVector32(), + incomingPaymentId = row.incoming_payment_id?.toByteVector32(), + createdAt = Instant.fromEpochMilliseconds(row.created_at) + ) + + } + private enum class ComparisonResult { IsNew, IsUpdated, @@ -312,4 +389,15 @@ class ContactQueries(val database: AppDatabase) { ComparisonResult.NoChanges } } + + private fun compareSecrets( + existing: ContactSecret, + current: ContactSecret + ): ComparisonResult { + return if (existing.incomingPaymentId != current.incomingPaymentId) { + ComparisonResult.IsUpdated + } else { + ComparisonResult.NoChanges + } + } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt index 4661e7178..2efe4cabf 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt @@ -21,6 +21,7 @@ import fr.acinq.bitcoin.PublicKey import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.WalletPayment import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.payment.OfferPaymentMetadata import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.PhoenixBusiness @@ -29,6 +30,7 @@ import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.data.WalletPaymentInfo import fr.acinq.phoenix.db.SqliteAppDb import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata +import fr.acinq.phoenix.utils.extensions.incomingOfferMetadataV2 import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope @@ -58,14 +60,14 @@ class ContactsManager( private val _offerMap = MutableStateFlow>(emptyMap()) val offerMap = _offerMap.asStateFlow() - // Key(Offer.contactNodeId), Value(ContactId) - private val _publicKeyMap = MutableStateFlow>(emptyMap()) - val publicKeyMap = _publicKeyMap.asStateFlow() - // Key(lightningAddress.hash), Value(ContactId) private val _addressMap = MutableStateFlow>(emptyMap()) val addressMap = _addressMap.asStateFlow() + // Key(SecretId), Value(ContactId) + private val _secretMap = MutableStateFlow>(emptyMap()) + val secretMap = _secretMap.asStateFlow() + init { launch { appDb.monitorContactsFlow().collect { list -> @@ -75,21 +77,21 @@ class ContactsManager( row.id to contact.id } }.toMap() - val newPublicKeyMap = list.flatMap { contact -> - contact.publicKeys.map { pubKey -> - pubKey to contact.id - } - }.toMap() val newAddressMap = list.flatMap { contact -> contact.addresses.map { row -> row.id to contact.id } }.toMap() + val newSecretMap = list.flatMap { contact -> + contact.secrets.map { row -> + row.id to contact.id + } + }.toMap() _contactsList.value = list _contactsMap.value = newMap _offerMap.value = newOfferMap - _publicKeyMap.value = newPublicKeyMap _addressMap.value = newAddressMap + _secretMap.value = newSecretMap } } } @@ -141,30 +143,30 @@ class ContactsManager( return contactForOfferId(offer.offerId) } - fun contactIdForPayerPubKey(payerPubKey: PublicKey): UUID? { - return publicKeyMap.value[payerPubKey] + fun contactIdForLightningAddress(address: String): UUID? { + return addressMap.value[ContactAddress.hash(address)] } - fun contactForPayerPubKey(payerPubKey: PublicKey): ContactInfo? { - return contactIdForPayerPubKey(payerPubKey)?.let { contactId -> + fun contactForLightningAddress(address: String): ContactInfo? { + return contactIdForLightningAddress(address)?.let { contactId -> contactForId(contactId) } } - fun contactIdForLightningAddress(address: String): UUID? { - return addressMap.value[ContactAddress.hash(address)] + fun contactIdForSecret(secret: ByteVector32): UUID? { + return secretMap.value[secret] } - fun contactForLightningAddress(address: String): ContactInfo? { - return contactIdForLightningAddress(address)?.let { contactId -> + fun contactForSecret(secret: ByteVector32): ContactInfo? { + return contactIdForSecret(secret)?.let { contactId -> contactForId(contactId) } } fun contactIdForPaymentInfo(paymentInfo: WalletPaymentInfo): UUID? { return if (paymentInfo.payment is IncomingPayment) { - paymentInfo.payment.incomingOfferMetadata()?.let { offerMetadata -> - contactIdForPayerPubKey(offerMetadata.payerKey) + paymentInfo.payment.incomingOfferMetadataV2()?.contactSecret?.let { secret -> + contactIdForSecret(secret) } } else { paymentInfo.metadata.lightningAddress?.let { address -> diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt index c5080d333..76f24e3a9 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt @@ -16,26 +16,22 @@ package fr.acinq.phoenix.managers -import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.NodeParams import fr.acinq.lightning.NodeUri import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.shared.BuildVersions import fr.acinq.lightning.logging.info -import fr.acinq.phoenix.data.OfferData +import fr.acinq.lightning.wire.OfferTypes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.hours class NodeParamsManager( @@ -81,11 +77,9 @@ class NodeParamsManager( } } - /** See [NodeParams.defaultOffer]. Returns an [OfferData] object. */ - suspend fun defaultOffer(): OfferData { - return nodeParams.filterNotNull().first().defaultOffer(trampolineNodeId).let { - OfferData(it.first, it.second) - } + /** See [NodeParams.defaultOffer] */ + suspend fun defaultOffer(): OfferTypes.OfferAndKey { + return nodeParams.filterNotNull().first().defaultOffer(trampolineNodeId) } companion object { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt index 846c985cb..1cd7af28b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt @@ -1,6 +1,7 @@ package fr.acinq.phoenix.managers import fr.acinq.bitcoin.BitcoinError +import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either @@ -471,6 +472,7 @@ class SendManager( lightningAddress: String?, payerKey: PrivateKey, payerNote: String?, + contactSecret: ByteVector32?, fetchInvoiceTimeout: Duration ): OfferNotPaid? { val peer = peerManager.getPeer() @@ -503,6 +505,7 @@ class SendManager( payerNote = payerNote, amount = amount, offer = offer, + contactSecret = contactSecret, fetchInvoiceTimeout = fetchInvoiceTimeout )) return res.await() diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt index 872e74345..bf479582f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt @@ -146,10 +146,7 @@ class CsvWriter { is LightningOutgoingPayment -> when (val details = payment.details) { is LightningOutgoingPayment.Details.Normal -> "Outgoing LN payment to ${details.paymentRequest.nodeId.toHex()}" is LightningOutgoingPayment.Details.SwapOut -> "Outgoing Swap to ${details.address}" - is LightningOutgoingPayment.Details.Blinded -> { - details.paymentRequest.invoiceRequest.offer.contactNodeIds.firstOrNull()?.let { "Outgoing LN payment (offer) to ${it.toHex()}" } - ?: "Outgoing LN payment (offer)" - } + is LightningOutgoingPayment.Details.Blinded -> "Outgoing LN payment (offer)" } is SpliceOutgoingPayment -> "Outgoing splice to ${payment.address}" is ChannelCloseOutgoingPayment -> "Channel closing to ${payment.address}" diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt index e85699cf8..fe0f2b1a4 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt @@ -100,7 +100,9 @@ fun WalletPayment.errorMessage(): String? = when (this) { is IncomingPayment -> null } -fun WalletPayment.incomingOfferMetadata(): OfferPaymentMetadata.V1? = ((this as? IncomingPayment)?.origin as? IncomingPayment.Origin.Offer)?.metadata as? OfferPaymentMetadata.V1 +fun WalletPayment.incomingOfferMetadata(): OfferPaymentMetadata? = ((this as? IncomingPayment)?.origin as? IncomingPayment.Origin.Offer)?.metadata +fun WalletPayment.incomingOfferMetadataV2(): OfferPaymentMetadata.V2? = this.incomingOfferMetadata() as? OfferPaymentMetadata.V2 + fun WalletPayment.outgoingInvoiceRequest(): OfferTypes.InvoiceRequest? = ((this as? LightningOutgoingPayment)?.details as? LightningOutgoingPayment.Details.Blinded)?.paymentRequest?.invoiceRequest /** Returns a list of the ids of the payments that triggered this liquidity purchase. May be empty, for example if this is a manual purchase. */ diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt index e29b00379..c81627382 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt @@ -53,5 +53,6 @@ val PaymentRequest.desc: String? val OfferPaymentMetadata.payerNote: String? get() = when { this is OfferPaymentMetadata.V1 -> this.payerNote + this is OfferPaymentMetadata.V2 -> this.payerNote else -> null } \ No newline at end of file diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt index 030f47885..18c485bc8 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt @@ -43,6 +43,8 @@ import fr.acinq.lightning.io.PaymentSent import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.PeerEvent import fr.acinq.lightning.io.TcpSocket +import fr.acinq.lightning.payment.ContactSecrets +import fr.acinq.lightning.payment.Contacts import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.payment.OutgoingPaymentFailure @@ -357,6 +359,13 @@ fun Lightning_randomBytes32(): ByteVector32 = Lightning.randomBytes32() fun Lightning_randomBytes64(): ByteVector64 = Lightning.randomBytes64() fun Lightning_randomKey(): PrivateKey = Lightning.randomKey() +fun Contacts_computeContactSecret( + ourOffer: OfferTypes.OfferAndKey, + theirOffer: OfferTypes.Offer +): ContactSecrets { + return Contacts.computeContactSecret(ourOffer, theirOffer) +} + fun NSData_toByteArray(data: NSData): ByteArray = data.toByteArray() fun NSData_copyTo(data: NSData, buffer: ByteArray, offset: Int = 0) = data.copyTo(buffer, offset) fun ByteArray_toNSDataSlice(buffer: ByteArray, offset: Int, length: Int): NSData = buffer.toNSData(offset = offset, length = length) @@ -383,6 +392,7 @@ suspend fun SendManager._payBolt12Offer( lightningAddress: String?, payerKey: PrivateKey, payerNote: String?, + contactSecret: ByteVector32?, fetchInvoiceTimeoutInSeconds: Int ): OfferNotPaid? { return payBolt12Offer( @@ -392,6 +402,7 @@ suspend fun SendManager._payBolt12Offer( lightningAddress = lightningAddress, payerKey = payerKey, payerNote = payerNote, + contactSecret = contactSecret, fetchInvoiceTimeout = fetchInvoiceTimeoutInSeconds.seconds ) }