diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f5a69f4f..15b868f5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -lightningkmp = "1.11.0" +lightningkmp = "1.10.9-SNAPSHOT" secp256k1 = "0.21.0" # keep in check with lightning-kmp secp version kotlin = "2.2.10" diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index b421d2f6a..a6991e1be 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -8086,6 +8086,9 @@ } } } + }, + "BLIP 42 (DEBUG build only)" : { + }, "Block height" : { "localizations" : { @@ -11624,6 +11627,9 @@ } } } + }, + "contact secret" : { + }, "Contact support if needed." : { "localizations" : { @@ -29316,6 +29322,9 @@ } } } + }, + "payer address" : { + }, "Payer key" : { "localizations" : { @@ -29356,6 +29365,9 @@ } } } + }, + "payer offer" : { + }, "Payment" : { "localizations" : { @@ -34205,6 +34217,9 @@ } } } + }, + "Secrets: (DEBUG build only)" : { + }, "Security" : { "extractionState" : "manual", diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift index 85e3fdf09..1555124d5 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift @@ -46,11 +46,7 @@ extension Lightning_kmpPaymentRequest { extension Lightning_kmpPeer { var bootChannelsFlowValue: Dictionary { - if let value = self.bootChannelsFlow.value as? Dictionary { - return value - } else { - return [:] - } + return self.bootChannelsFlow.value ?? [:] } } diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index ab15bda27..530c88091 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -132,7 +132,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 @@ -165,7 +165,24 @@ extension WalletPaymentInfo { func addToContactsInfo() -> AddToContactsInfo? { - if payment is Lightning_kmpOutgoingPayment { + if let incoming = payment as? Lightning_kmpIncomingPayment { + + if let metadata = payment.incomingOfferMetadata() { + 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.id, + createdAt: Date.now.toMilliseconds() + ) + 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). @@ -177,12 +194,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/kotlin/KotlinTypes.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift index f2f1e0c58..a327eab18 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift @@ -21,6 +21,8 @@ typealias Lightning_kmpChannelManagementFees = Lightning_kmp_coreChannelManageme typealias Lightning_kmpChannelState = Lightning_kmp_coreChannelState typealias Lightning_kmpConnection = Lightning_kmp_coreConnection typealias Lightning_kmpClosing = Lightning_kmp_coreClosing +typealias Lightning_kmpContactAddress = Lightning_kmp_coreContactAddress +typealias Lightning_kmpContactSecrets = Lightning_kmp_coreContactSecrets typealias Lightning_kmpDatabases = Lightning_kmp_coreDatabases typealias Lightning_kmpElectrumClient = Lightning_kmp_coreElectrumClient typealias Lightning_kmpElectrumMiniWallet = Lightning_kmp_coreElectrumMiniWallet 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 18cd0cf66..7afaac5d2 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 @@ -184,7 +187,6 @@ struct ManageContact: View { hasNewOffer = true } } - self._offers = State(initialValue: rows) self._offers_hasChanges = State(initialValue: (contact != nil && hasNewOffer)) @@ -214,11 +216,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)) + } } // -------------------------------------------------- @@ -397,6 +422,9 @@ struct ManageContact: View { content_trusted() content_offers() content_addresses() + #if DEBUG + content_secrets() + #endif } // .padding() } // @@ -934,6 +962,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?.description() ?? "" )") + .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 { @@ -1290,7 +1379,7 @@ struct ManageContact: View { if doNotUseDiskImage { return true } - if offers_hasChanges || addresses_hasChanges { + if offers_hasChanges || addresses_hasChanges || secrets_hasChanges { return true } @@ -1487,9 +1576,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)") + let contactsDb = try await Biz.business.databaseManager.contactsDb() try await contactsDb.saveContact(contact: updatedContact) diff --git a/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift b/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift index aed3e8662..0e4cb28fd 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift @@ -454,6 +454,88 @@ extension DetailsInfoGrid { } } + // -------------------------------------------------- + // MARK: Section: BLIP 42 + // -------------------------------------------------- + #if DEBUG + + @ViewBuilder + func section_blip42( + _ secret: Bitcoin_kmpByteVector32?, + _ offer: Lightning_kmpOfferTypesOffer?, + _ address: Lightning_kmp_coreUnverifiedContactAddress? + ) -> some View { + + InlineSection { + header("BLIP 42 (DEBUG build only)") + } content: { + blip42_contactSecret(secret) + blip42_offer(offer) + blip42_address(address) + } + } + + @ViewBuilder + func blip42_contactSecret( + _ secret: Bitcoin_kmpByteVector32? + ) -> some View { + + detailsRow( + identifier: #function, + keyColumnTitle: "contact secret" + ) { + if let str = secret?.toHex() { + Text(str) + .lineLimit(2) + .truncationMode(.middle) + } else { + Text(verbatim: "") + .foregroundStyle(Color.secondary) + } + } + } + + @ViewBuilder + func blip42_offer( + _ offer: Lightning_kmpOfferTypesOffer? + ) -> some View { + + detailsRow( + identifier: #function, + keyColumnTitle: "payer offer" + ) { + if let str = offer?.encode() { + Text(str) + .lineLimit(2) + .truncationMode(.middle) + } else { + Text(verbatim: "") + .foregroundStyle(Color.secondary) + } + } + } + + @ViewBuilder + func blip42_address( + _ address: Lightning_kmp_coreUnverifiedContactAddress? + ) -> some View { + + detailsRow( + identifier: #function, + keyColumnTitle: "payer address" + ) { + if let str = address?.description() { + Text(str) + .lineLimit(2) + .truncationMode(.middle) + } else { + Text(verbatim: "") + .foregroundStyle(Color.secondary) + } + } + } + + #endif // -------------------------------------------------- // MARK: SubSection: Bolt11 Invoice // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Incoming_Bolt12.swift b/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Incoming_Bolt12.swift index db73261b9..08f083218 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Incoming_Bolt12.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Incoming_Bolt12.swift @@ -43,6 +43,10 @@ struct Details_Incoming_Bolt12: DetailsInfoGrid { section_timestamps() section_incoming() section_lightningParts(payment) + #if DEBUG + let metadata = payment.metadata + section_blip42(metadata.contactSecret_, metadata.payerOffer_, metadata.payerAddress_) + #endif } } .background(Color.primaryBackground) diff --git a/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Outgoing_Lightning.swift b/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Outgoing_Lightning.swift index 395441323..8bff202f0 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Outgoing_Lightning.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/Details/Details_Outgoing_Lightning.swift @@ -42,6 +42,11 @@ struct Details_Outgoing_Lightning: DetailsInfoGrid { section_general() section_timestamps() section_outgoing() + #if DEBUG + if let invReq = payment.outgoingInvoiceRequest() { + section_blip42(invReq.contactSecret, invReq.payerOffer, invReq.payerAddress) + } + #endif } } .background(Color.primaryBackground) diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 2c8e344d1..3d976813a 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -1718,7 +1718,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.databaseManager.contactsDbValue()?.contactsListCount() ?? 0 if count == 0 { @@ -1901,11 +1901,54 @@ struct ValidateView: View { Biz.beginLongLivedTask(id: paymentId.description()) let payerKey: Bitcoin_kmpPrivateKey - if contact?.useOfferKey ?? false { + let contactSecret: Bitcoin_kmpByteVector32? + + if let contact, contact.useOfferKey { let offerAndKey = 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.toMilliseconds() + ) + let updatedContact = contact.doCopy( + id : contact.id, + name : contact.name, + photoUri : contact.photoUri, + useOfferKey : contact.useOfferKey, + offers : contact.offers, + addresses : contact.addresses, + secrets : [newSecret] + ) + + let contactsDb = try await Biz.business.databaseManager.contactsDb() + try await contactsDb.saveContact(contact: updatedContact) + + // Use the newly generated secret for this payment + contactSecret = newSecret.id + } + } else { payerKey = Lightning_randomKey() + contactSecret = nil } let response: Lightning_kmpOfferNotPaid? = @@ -1916,6 +1959,7 @@ struct ValidateView: View { lightningAddress: model.lightningAddress, payerKey: payerKey, payerNote: payerNote, + contactSecret: contactSecret, fetchInvoiceTimeoutInSeconds: 30 ) 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 e8d989cb6..9ced1b3d2 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 @@ -83,6 +83,12 @@ data class ContactAddress( } } +data class ContactSecret( + val id: ByteVector32, + val incomingPaymentId: UUID?, + val createdAt: Long +) + data class ContactInfo( val id: UUID, val name: String, @@ -90,26 +96,11 @@ data class ContactInfo( val useOfferKey: Boolean, val offers: List, val addresses: List, - val publicKeys: List, + val secrets: 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.contactInfos.map { it.nodeId } }.flatten() - ) + val publicKeys: List + get() = offers.map { it.offer.contactInfos.map { it.nodeId } }.flatten() /** List the offers and LN addresses attached to the contact, ordered by creation date (most recent on top). */ val paymentCodes: List by lazy { (offers + addresses).sortedByDescending { it.createdAt } } - } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt index ba49d437a..186624b35 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt @@ -164,13 +164,7 @@ class SqlitePaymentsDb( private fun combinePaymentAndContact(paymentInfoList: List, indexes: SqliteContactsDb.ContactIndexes): List = paymentInfoList.map { paymentInfo -> val payment = paymentInfo.payment val metadata = paymentInfo.metadata - val contactId: UUID? = when (payment) { - is Bolt12IncomingPayment -> payment.incomingOfferMetadata()?.let { indexes.publicKeysMap[it.payerKey] } - is LightningOutgoingPayment -> payment.outgoingInvoiceRequest()?.let { indexes.offersMap[it.offer.offerId] } - else -> metadata.lightningAddress?.let { indexes.addressesMap[ContactAddress.hash(it)] } - } - - contactId?.let { indexes.contactsMap[it] }?.let { + indexes.contactForPayment(payment, metadata)?.let { paymentInfo.copy(contact = it) } ?: paymentInfo } 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 edc54ae21..72ab53536 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 @@ -50,7 +50,8 @@ sealed class CloudContact { photoUri = photoUri, useOfferKey = this.useOfferKey, offers = mappedOffers, - addresses = listOf() + addresses = listOf(), + secrets = listOf() ) } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/contacts/SqliteContactsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/contacts/SqliteContactsDb.kt index f32b5512b..659d6c46c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/contacts/SqliteContactsDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/contacts/SqliteContactsDb.kt @@ -14,8 +14,10 @@ import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.db.SqliteAppDb import fr.acinq.phoenix.db.migrations.appDb.v7.AfterVersion7Result import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase +import fr.acinq.phoenix.utils.extensions.contactSecret import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest +import fr.acinq.phoenix.utils.extensions.payerKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -49,7 +51,71 @@ class SqliteContactsDb( val offersMap: Map, val publicKeysMap: Map, val addressesMap: Map, - ) + val secretsMap: Map + ) { + fun contactForId(contactId: UUID): ContactInfo? { + return contactsMap[contactId] + } + + private fun contactIdForOfferId(offerId: ByteVector32): UUID? { + return offersMap[offerId] + } + fun contactForOfferId(offerId: ByteVector32): ContactInfo? { + return contactIdForOfferId(offerId)?.let { contactId -> + contactForId(contactId) + } + } + + fun contactForOffer(offer: OfferTypes.Offer): ContactInfo? { + return contactForOfferId(offer.offerId) + } + + private fun contactIdForPayerPubKey(payerPubKey: PublicKey): UUID? { + return publicKeysMap[payerPubKey] + } + + private fun contactIdForLightningAddress(address: String): UUID? { + return addressesMap[ContactAddress.hash(address)] + } + fun contactForLightningAddress(address: String): ContactInfo? { + return contactIdForLightningAddress(address)?.let { contactId -> + contactForId(contactId) + } + } + + private fun contactIdForSecret(secret: ByteVector32): UUID? { + return secretsMap[secret] + } + fun contactForSecret(secret: ByteVector32): ContactInfo? { + return contactIdForSecret(secret)?.let { contactId -> + contactForId(contactId) + } + } + + private fun contactIdForPayment(payment: WalletPayment, metadata: WalletPaymentMetadata?): UUID? { + return if (payment is Bolt12IncomingPayment) { + payment.incomingOfferMetadata()?.let { offerMetadata -> + offerMetadata.contactSecret?.let { secret -> + contactIdForSecret(secret) + } ?: offerMetadata.payerKey?.let { payerPubKey -> + contactIdForPayerPubKey(payerPubKey) + } + } + } else { + metadata?.lightningAddress?.let { address -> + contactIdForLightningAddress(address) + } ?: payment.outgoingInvoiceRequest()?.let { invoiceRequest -> + contactIdForOfferId(invoiceRequest.offer.offerId) + } + } + } + + fun contactForPayment(payment: WalletPayment, metadata: WalletPaymentMetadata?): ContactInfo? { + return contactIdForPayment(payment, metadata)?.let { contactId -> + contactForId(contactId) + } + } + } @OptIn(ExperimentalCoroutinesApi::class) val indexesFlow = contactsList.mapLatest { list -> @@ -63,12 +129,15 @@ class SqliteContactsDb( }.toMap(), addressesMap = list.flatMap { contact -> contact.addresses.map { it.id to contact.id } + }.toMap(), + secretsMap = list.flatMap { contact -> + contact.secrets.map { it.id to contact.id } }.toMap() ) }.stateIn( scope = this, started = SharingStarted.Eagerly, - initialValue = ContactIndexes(emptyMap(), emptyMap(), emptyMap(), emptyMap()) + initialValue = ContactIndexes(emptyMap(), emptyMap(), emptyMap(), emptyMap(), emptyMap()) ) init { @@ -91,64 +160,28 @@ class SqliteContactsDb( * There's generally no need to query the database since we have everything in memory. */ - fun contactForPayment(payment: WalletPayment, metadata: WalletPaymentMetadata?): ContactInfo? { - return contactIdForPayment(payment, metadata)?.let { contactId -> - contactForId(contactId) - } + fun contactForId(contactId: UUID): ContactInfo? { + return indexesFlow.value.contactForId(contactId) } - fun contactForOffer(offer: OfferTypes.Offer): ContactInfo? { - return contactForOfferId(offer.offerId) + fun contactForOfferId(offerId: ByteVector32): ContactInfo? { + return indexesFlow.value.contactForOfferId(offerId) } - fun contactForPayerPubKey(payerPubKey: PublicKey): ContactInfo? { - return contactIdForPayerPubKey(payerPubKey)?.let { contactId -> - contactForId(contactId) - } + fun contactForOffer(offer: OfferTypes.Offer): ContactInfo? { + return indexesFlow.value.contactForOffer(offer) } fun contactForLightningAddress(address: String): ContactInfo? { - return contactIdForLightningAddress(address)?.let { contactId -> - contactForId(contactId) - } - } - - private fun contactForId(contactId: UUID): ContactInfo? { - return indexesFlow.value.contactsMap[contactId] + return indexesFlow.value.contactForLightningAddress(address) } - private fun contactForOfferId(offerId: ByteVector32): ContactInfo? { - return contactIdForOfferId(offerId)?.let { contactId -> - contactForId(contactId) - } - } - - private fun contactIdForOfferId(offerId: ByteVector32): UUID? { - return indexesFlow.value.offersMap[offerId] - } - - private fun contactIdForPayerPubKey(payerPubKey: PublicKey): UUID? { - return indexesFlow.value.publicKeysMap[payerPubKey] - } - - private fun contactIdForLightningAddress(address: String): UUID? { - return indexesFlow.value.addressesMap[ContactAddress.hash(address)] + fun contactForSecret(secret: ByteVector32): ContactInfo? { + return indexesFlow.value.contactForSecret(secret) } - private fun contactIdForPayment(payment: WalletPayment, metadata: WalletPaymentMetadata?): UUID? { - return if (payment is Bolt12IncomingPayment) { - payment.incomingOfferMetadata()?.let { offerMetadata -> - offerMetadata.payerKey?.let { payerKey -> - contactIdForPayerPubKey(payerKey) - } - } - } else { - metadata?.lightningAddress?.let { address -> - contactIdForLightningAddress(address) - } ?: payment.outgoingInvoiceRequest()?.let { invoiceRequest -> - contactIdForOfferId(invoiceRequest.offer.offerId) - } - } + fun contactForPayment(payment: WalletPayment, metadata: WalletPaymentMetadata?): ContactInfo? { + return indexesFlow.value.contactForPayment(payment, metadata) } /** diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/migrations/appDb/v7/AfterVersion7.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/migrations/appDb/v7/AfterVersion7.kt index ecf266df4..336564077 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/migrations/appDb/v7/AfterVersion7.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/migrations/appDb/v7/AfterVersion7.kt @@ -130,7 +130,8 @@ fun AfterVersion7( photoUri = photo_uri, useOfferKey = use_offer_key, offers = listOf(), - addresses = listOf() + addresses = listOf(), + secrets = listOf() ) } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/Serialization.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/Serialization.kt index 2d90875ee..e590d0a5a 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/Serialization.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/Serialization.kt @@ -5,13 +5,14 @@ import fr.acinq.phoenix.data.ContactInfo object Serialization { fun serialize(contact: ContactInfo): ByteArray { - return fr.acinq.phoenix.db.serialization.contacts.v1.Serialization.serialize(contact) + return fr.acinq.phoenix.db.serialization.contacts.v2.Serialization.serialize(contact) } fun deserialize(bin: ByteArray): Result { return runCatching { when (val version = bin.first().toInt()) { 1 -> fr.acinq.phoenix.db.serialization.contacts.v1.Deserialization.deserialize(bin) + 2 -> fr.acinq.phoenix.db.serialization.contacts.v2.Deserialization.deserialize(bin) else -> error("unknown version $version") } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v1/Deserialization.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v1/Deserialization.kt index d8e3a65cb..294c7409b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v1/Deserialization.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v1/Deserialization.kt @@ -16,10 +16,12 @@ import fr.acinq.phoenix.data.ContactOffer object Deserialization { + const val VERSION_MAGIC = 1 + fun deserialize(bin: ByteArray): ContactInfo { val input = ByteArrayInput(bin) val version = input.read() - require(version == Serialization.VERSION_MAGIC) { "incorrect version $version, expected ${Serialization.VERSION_MAGIC}" } + require(version == VERSION_MAGIC) { "incorrect version $version, expected ${VERSION_MAGIC}" } return input.readContactInfo() } @@ -29,7 +31,8 @@ object Deserialization { photoUri = readNullable { readString() }, useOfferKey = readBoolean(), offers = readCollection { readContactOffer() }.toList(), - addresses = readCollection { readContactAddress() }.toList() + addresses = readCollection { readContactAddress() }.toList(), + secrets = listOf() ) private fun Input.readContactOffer() = ContactOffer( diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v2/Deserialization.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v2/Deserialization.kt new file mode 100644 index 000000000..47ac00fa7 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v2/Deserialization.kt @@ -0,0 +1,56 @@ +package fr.acinq.phoenix.db.serialization.contacts.v2 + +import fr.acinq.bitcoin.io.ByteArrayInput +import fr.acinq.bitcoin.io.Input +import fr.acinq.lightning.serialization.InputExtensions.readBoolean +import fr.acinq.lightning.serialization.InputExtensions.readByteVector32 +import fr.acinq.lightning.serialization.InputExtensions.readCollection +import fr.acinq.lightning.serialization.InputExtensions.readNullable +import fr.acinq.lightning.serialization.InputExtensions.readNumber +import fr.acinq.lightning.serialization.InputExtensions.readString +import fr.acinq.lightning.serialization.InputExtensions.readUuid +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 + +object Deserialization { + + fun deserialize(bin: ByteArray): ContactInfo { + val input = ByteArrayInput(bin) + val version = input.read() + require(version == Serialization.VERSION_MAGIC) { "incorrect version $version, expected ${Serialization.VERSION_MAGIC}" } + return input.readContactInfo() + } + + private fun Input.readContactInfo() = ContactInfo( + id = readUuid(), + name = readString(), + photoUri = readNullable { readString() }, + useOfferKey = readBoolean(), + offers = readCollection { readContactOffer() }.toList(), + addresses = readCollection { readContactAddress() }.toList(), + secrets = readCollection { readContactSecret() }.toList() + ) + + private fun Input.readContactOffer() = ContactOffer( + id = readByteVector32(), + offer = OfferTypes.Offer.decode(readString()).get(), + label = readNullable { readString() }, + createdAt = readNumber() + ) + + private fun Input.readContactAddress() = ContactAddress( + id = readByteVector32(), + address = readString(), + label = readNullable { readString() }, + createdAt = readNumber() + ) + + private fun Input.readContactSecret() = ContactSecret( + id = readByteVector32(), + incomingPaymentId = readNullable { readUuid() }, + createdAt = readNumber() + ) +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v1/Serialization.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v2/Serialization.kt similarity index 81% rename from phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v1/Serialization.kt rename to phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v2/Serialization.kt index 85cdca520..b99cc693f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v1/Serialization.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serialization/contacts/v2/Serialization.kt @@ -1,4 +1,4 @@ -package fr.acinq.phoenix.db.serialization.contacts.v1 +package fr.acinq.phoenix.db.serialization.contacts.v2 import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.io.Output @@ -12,10 +12,11 @@ import fr.acinq.lightning.serialization.OutputExtensions.writeUuid import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.data.ContactOffer +import fr.acinq.phoenix.data.ContactSecret object Serialization { - const val VERSION_MAGIC = 1 + const val VERSION_MAGIC = 2 fun serialize(o: ContactInfo): ByteArray { val out = ByteArrayOutput() @@ -31,6 +32,7 @@ object Serialization { writeBoolean(o.useOfferKey) writeCollection(o.offers) { writeContactOffer(it) } writeCollection(o.addresses) { writeContactAddress(it) } + writeCollection(o.secrets) { writeContactSecret(it) } } private fun Output.writeContactOffer(o: ContactOffer) { @@ -46,4 +48,10 @@ object Serialization { writeNullable(o.label) { writeString(it) } writeNumber(o.createdAt) } + + private fun Output.writeContactSecret(o: ContactSecret) { + writeByteVector32(o.id) + writeNullable(o.incomingPaymentId) { writeUuid(it) } + writeNumber(o.createdAt) + } } \ No newline at end of file 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 277c02d90..c05e5e6f3 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 @@ -479,6 +480,7 @@ class SendManager( lightningAddress: String?, payerKey: PrivateKey, payerNote: String?, + contactSecret: ByteVector32?, fetchInvoiceTimeoutInSeconds: Int ): OfferNotPaid? { val peer = peerManager.getPeer() @@ -506,6 +508,7 @@ class SendManager( payerNote = payerNote, amount = amount, offer = offer, + contactSecret = contactSecret, fetchInvoiceTimeout = fetchInvoiceTimeoutInSeconds.seconds )) return res.await() 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 e3c94dea5..956941499 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 @@ -16,11 +16,14 @@ package fr.acinq.phoenix.utils.extensions +import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PublicKey import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.payment.OfferPaymentMetadata import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.payment.UnverifiedContactAddress +import fr.acinq.lightning.wire.OfferTypes /** @@ -39,4 +42,40 @@ val OfferPaymentMetadata.description: String? get() = when (this) { is OfferPaymentMetadata.V1 -> null is OfferPaymentMetadata.V2 -> this.description + is OfferPaymentMetadata.V3 -> this.description + } + +val OfferPaymentMetadata.payerKey: PublicKey? + get() = when (this) { + is OfferPaymentMetadata.V1 -> this.payerKey + is OfferPaymentMetadata.V2 -> this.payerKey + is OfferPaymentMetadata.V3 -> this.payerKey + } + +val OfferPaymentMetadata.payerNote: String? + get() = when (this) { + is OfferPaymentMetadata.V1 -> this.payerNote + is OfferPaymentMetadata.V2 -> this.payerNote + is OfferPaymentMetadata.V3 -> this.payerNote + } + +val OfferPaymentMetadata.contactSecret: ByteVector32? + get() = when (this) { + is OfferPaymentMetadata.V1 -> null + is OfferPaymentMetadata.V2 -> null + is OfferPaymentMetadata.V3 -> this.contactSecret + } + +val OfferPaymentMetadata.payerOffer: OfferTypes.Offer? + get() = when (this) { + is OfferPaymentMetadata.V1 -> null + is OfferPaymentMetadata.V2 -> null + is OfferPaymentMetadata.V3 -> this.payerOffer + } + +val OfferPaymentMetadata.payerAddress: UnverifiedContactAddress? + get() = when (this) { + is OfferPaymentMetadata.V1 -> null + is OfferPaymentMetadata.V2 -> null + is OfferPaymentMetadata.V3 -> this.payerAddress } 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 65f0ff65c..616b685bb 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 @@ -37,6 +37,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.OfferManager @@ -330,6 +332,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)