From 63a3fe556cf4e8be66b075babed1a2cd2a7f6b09 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 13:50:04 +0100 Subject: [PATCH 01/10] Merge pull request #1237 from synonymdev/chore/paykit-rc6 chore: adopt Paykit 0.1.1-rc6 (cherry picked from commit fe0cc2de476c47ba78f55df790bc8d93836515c5) --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Models/PaykitUsdtStateBackup.swift | 5 +- .../Localization/ar.lproj/Localizable.strings | 4 + .../Localization/ca.lproj/Localizable.strings | 4 + .../Localization/cs.lproj/Localizable.strings | 4 + .../Localization/de.lproj/Localizable.strings | 4 + .../Localization/el.lproj/Localizable.strings | 4 + .../Localization/en.lproj/Localizable.strings | 4 + .../es-419.lproj/Localizable.strings | 4 + .../Localization/es.lproj/Localizable.strings | 4 + .../Localization/fr.lproj/Localizable.strings | 4 + .../Localization/it.lproj/Localizable.strings | 4 + .../Localization/nl.lproj/Localizable.strings | 4 + .../Localization/pl.lproj/Localizable.strings | 4 + .../pt-BR.lproj/Localizable.strings | 4 + .../Localization/pt.lproj/Localizable.strings | 4 + .../Localization/ru.lproj/Localizable.strings | 4 + Bitkit/Services/PaykitAllowanceExecutor.swift | 14 ++ .../Services/PaykitPaymentProofService.swift | 64 ++++++++ .../PaykitPaymentRequestService.swift | 33 +++- .../Services/PaykitUsdtPaymentService.swift | 48 +++--- Bitkit/Services/PubkyService.swift | 4 +- .../PaymentRequests/PaymentRequestsView.swift | 1 + .../PubkyAuthApprovalSheet.swift | 15 +- BitkitTests/ContactPaymentsServiceTests.swift | 2 +- BitkitTests/HwFundingSignerTests.swift | 2 + .../PaykitAllowanceExecutorTests.swift | 16 ++ BitkitTests/PaykitAllowanceTests.swift | 11 +- BitkitTests/PaykitContactLifecycleTests.swift | 11 +- BitkitTests/PaykitIssuerInteropTests.swift | 2 + .../PaykitPaymentProofServiceTests.swift | 84 +++++++++- .../PaykitPaymentRequestServiceTests.swift | 152 ++++++++++++++++++ .../PaykitPaymentStateBackupTests.swift | 26 +++ .../PaykitReceivedPaymentContactsTests.swift | 4 +- BitkitTests/PaykitRequestPricingTests.swift | 72 +++++++++ BitkitTests/PaykitSdkIdentityCheckTests.swift | 4 +- BitkitTests/PaykitSdkOperationLockTests.swift | 2 +- .../PaymentNavigationHelperTests.swift | 2 + BitkitTests/PrivatePaykitServiceTests.swift | 51 +++--- BitkitTests/PubkyAuthApprovalSheetTests.swift | 22 +++ BitkitTests/ReceiveSheetSessionTests.swift | 2 + BitkitTests/SendSheetLifecycleTests.swift | 4 +- .../TransferServiceActivityTests.swift | 2 + changelog.d/next/1237.fixed.md | 1 + journeys/payment-requests/README.md | 4 + .../request-note-too-long.xml | 18 +++ journeys/pubky-auth/README.md | 7 +- .../pubky-auth/grant-signup-rejected-code.xml | 17 ++ 49 files changed, 693 insertions(+), 75 deletions(-) create mode 100644 changelog.d/next/1237.fixed.md create mode 100644 journeys/payment-requests/request-note-too-long.xml create mode 100644 journeys/pubky-auth/grant-signup-rejected-code.xml diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index c61f3a4c9..7b58a6389 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1200,7 +1200,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.1-rc2"; + version = "0.1.1-rc6"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a921d6b3..010c47429 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "01de5dd232d2b22f132d066697cce041700e7136", - "version" : "0.1.1-rc2" + "revision" : "9919198a8a810652945f7366defacae8c3135632", + "version" : "0.1.1-rc6" } }, { diff --git a/Bitkit/Models/PaykitUsdtStateBackup.swift b/Bitkit/Models/PaykitUsdtStateBackup.swift index 4aeef57a2..2c4f72180 100644 --- a/Bitkit/Models/PaykitUsdtStateBackup.swift +++ b/Bitkit/Models/PaykitUsdtStateBackup.swift @@ -60,6 +60,7 @@ struct PaykitUsdtStateBackup: Codable { let receivedAtMillis: Int64 let underpaid: Bool let afterExpiry: Bool + let beforeRequest: Bool? init(_ value: PaykitUsdtPaymentService.Receipt) { wallet = value.wallet @@ -76,6 +77,7 @@ struct PaykitUsdtStateBackup: Codable { receivedAtMillis = Int64(value.receivedAt.timeIntervalSince1970 * 1000) underpaid = value.underpaid afterExpiry = value.afterExpiry + beforeRequest = value.beforeRequest } func restored() throws -> PaykitUsdtPaymentService.Receipt { @@ -89,7 +91,8 @@ struct PaykitUsdtStateBackup: Codable { billingPeriodStartsAt: periodStart), paymentId: paymentId, proofEventId: proofEventId, verified: verified, transferId: transferId, amount: PaykitAmount(asset: .usdt, atomic: amountAtomic), - receivedAt: Date(timeIntervalSince1970: Double(receivedAtMillis) / 1000), underpaid: underpaid, afterExpiry: afterExpiry + receivedAt: Date(timeIntervalSince1970: Double(receivedAtMillis) / 1000), underpaid: underpaid, afterExpiry: afterExpiry, + beforeRequest: beforeRequest ?? false ) } } diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 1d039e7f3..d60006bab 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -564,6 +564,8 @@ "pubky_auth__homeserver" = "الخادم الرئيسي"; "pubky_auth__signup_description" = "أنشئ هوية Pubky جديدة على هذا الخادم الرئيسي. تابع فقط إذا كنت تثق به."; "pubky_auth__approval_failed" = "فشل التفويض"; +"pubky_auth__identity_unavailable_desc" = "تحقق من اتصالك وحاول مرة أخرى."; +"profile__auth_error_signup_code_rejected" = "لم يقبل الخادم الرئيسي رمز التسجيل هذا. حاول مرة أخرى أو استخدم رابط تسجيل آخر."; "wallet__onchain_abandon_transfer_note" = "لم تعد التحويلات إلى رصيد الإنفاق متاحة. يتحقق Bitkit أولًا من الشبكة، ثم يعيد عملات هذا التحويل إلى محفظتك حتى لا يتأكد التحويل الأصلي أبدًا. يكلّف ذلك رسوم شبكة."; "watch_only_accounts__active_section" = "الحسابات النشطة"; "watch_only_accounts__copy_xpub" = "نسخ xpub"; @@ -1220,6 +1222,7 @@ "wallet__payment_request_payment_verified" = "تم التحقق من الدفع"; "wallet__payment_request_underpaid" = "مستلم · دفع ناقص"; "wallet__payment_request_after_expiry" = "مستلم · بعد انتهاء الصلاحية"; +"wallet__payment_request_before_request" = "مستلم · قبل الطلب"; "pubky_auth__usdt_intro_description" = "لتربح، تحتاج إلى مشاركة عنوان USDT الخاص بك مع Paykit. يمكنه عرض نشاط المبيعات، لكنه لا يستطيع إنفاق الأموال."; "pubky_auth__usdt_intro_optional" = "يمكنك مشاركة عنوان USDT الخاص بك مع Paykit لاستلام الأرباح. يمكنه عرض نشاط المبيعات، لكنه لا يستطيع إنفاق الأموال."; "pubky_auth__usdt_unavailable" = "عنوان USDT الخاص بك غير متاح. يرجى المحاولة مرة أخرى."; @@ -1279,6 +1282,7 @@ "usdt__deposit_refund_address_network" = "عنوان USDT على {network}"; "wallet__payment_request" = "طلب دفع"; "wallet__payment_request_unavailable" = "لم يعد طلب الدفع متاحًا."; +"wallet__payment_request_note_too_long" = "اختصر الملاحظة وحاول مرة أخرى."; "wallet__payment_request_expired" = "انتهت صلاحية طلب الدفع."; "wallet__payment_request_waiting_for_details" = "لا تزال تفاصيل الدفع قيد الاستعادة. حاول مرة أخرى قريبًا."; "wallet__payment_request_in_progress" = "طلب الدفع قيد المعالجة بالفعل."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index d6efac452..c8db7bac5 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -648,6 +648,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Crea una nova identitat Pubky en aquest homeserver. Continua només si hi confies."; "pubky_auth__approval_failed" = "Ha fallat l'autorització"; +"pubky_auth__identity_unavailable_desc" = "Comprova la connexió i torna-ho a provar."; +"profile__auth_error_signup_code_rejected" = "L'homeserver no ha acceptat aquest codi de registre. Torna-ho a provar o utilitza un altre enllaç de registre."; "wallet__onchain_abandon_transfer_note" = "Les transferències al saldo de despesa ja no estan disponibles. Bitkit primer comprova la xarxa i després retorna les monedes d'aquesta transferència a la teva cartera perquè la transferència original no es pugui confirmar mai. Això té una comissió de xarxa."; "watch_only_accounts__active_section" = "Comptes actius"; "watch_only_accounts__copy_xpub" = "Copiar xpub"; @@ -1318,6 +1320,7 @@ "wallet__payment_received" = "Rebut Bitcoin"; "wallet__payment_request" = "Sol·licitud de pagament"; "wallet__payment_request_unavailable" = "La sol·licitud de pagament ja no està disponible."; +"wallet__payment_request_note_too_long" = "Escurça la nota i torna-ho a provar."; "wallet__payment_request_expired" = "La sol·licitud de pagament ha caducat."; "wallet__payment_request_waiting_for_details" = "Encara s'estan recuperant les dades de pagament. Torna-ho a provar d'aquí a poc."; "wallet__payment_request_in_progress" = "La sol·licitud de pagament ja s'està processant."; @@ -1640,6 +1643,7 @@ "wallet__payment_request_payment_verified" = "Pagament verificat"; "wallet__payment_request_underpaid" = "Rebut · Pagament insuficient"; "wallet__payment_request_after_expiry" = "Rebut · Després del venciment"; +"wallet__payment_request_before_request" = "Rebut · Abans de la sol·licitud"; "pubky_auth__usdt_intro_description" = "Per guanyar, has de compartir amb Paykit la teva adreça USDT. Pot veure l'activitat de vendes, però no pot gastar fons."; "pubky_auth__usdt_intro_optional" = "Pots compartir amb Paykit la teva adreça USDT per rebre guanys. Pot veure l'activitat de vendes, però no pot gastar fons."; "pubky_auth__usdt_unavailable" = "La teva adreça USDT no està disponible. Torna-ho a provar."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 0b058c5d4..05c8fd16c 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -708,6 +708,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Vytvořte si na tomto homeserveru novou identitu Pubky. Pokračujte, jen pokud mu důvěřujete."; "pubky_auth__approval_failed" = "Autorizace se nezdařila"; +"pubky_auth__identity_unavailable_desc" = "Zkontrolujte připojení a zkuste to znovu."; +"profile__auth_error_signup_code_rejected" = "Homeserver tento registrační kód nepřijal. Zkuste to znovu nebo použijte jiný registrační odkaz."; "wallet__onchain_abandon_transfer_note" = "Převody na útratový zůstatek už nejsou dostupné. Bitkit nejprve zkontroluje síť a pak vrátí mince tohoto převodu do vaší peněženky, aby se původní převod nikdy nepotvrdil. Stojí to síťový poplatek."; "watch_only_accounts__active_section" = "Aktivní účty"; "watch_only_accounts__copy_xpub" = "Kopírovat xpub"; @@ -1441,6 +1443,7 @@ "wallet__payment_received" = "bitcoin přijatý"; "wallet__payment_request" = "Žádost o platbu"; "wallet__payment_request_unavailable" = "Žádost o platbu již není k dispozici."; +"wallet__payment_request_note_too_long" = "Zkraťte poznámku a zkuste to znovu."; "wallet__payment_request_expired" = "Platnost žádosti o platbu vypršela."; "wallet__payment_request_waiting_for_details" = "Platební údaje se stále obnovují. Zkuste to za chvíli znovu."; "wallet__payment_request_in_progress" = "Žádost o platbu se již zpracovává."; @@ -1780,6 +1783,7 @@ "wallet__payment_request_payment_verified" = "Platba ověřena"; "wallet__payment_request_underpaid" = "Přijato · Nedoplatek"; "wallet__payment_request_after_expiry" = "Přijato · Po vypršení"; +"wallet__payment_request_before_request" = "Přijato · Před žádostí"; "pubky_auth__usdt_intro_description" = "Abyste mohli vydělávat, musíte s Paykit sdílet svou adresu USDT. Může zobrazovat prodejní aktivitu, ale nemůže utrácet prostředky."; "pubky_auth__usdt_intro_optional" = "Svou adresu USDT můžete sdílet s Paykit a přijímat tak výdělky. Může zobrazovat prodejní aktivitu, ale nemůže utrácet prostředky."; "pubky_auth__usdt_unavailable" = "Vaše adresa USDT není k dispozici. Zkuste to prosím znovu."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 87ba20a48..1e18ebc02 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -706,6 +706,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Erstelle eine neue Pubky-Identität auf diesem Homeserver. Fahre nur fort, wenn du ihm vertraust."; "pubky_auth__approval_failed" = "Autorisierung fehlgeschlagen"; +"pubky_auth__identity_unavailable_desc" = "Überprüfe deine Verbindung und versuche es erneut."; +"profile__auth_error_signup_code_rejected" = "Der Homeserver hat diesen Registrierungscode nicht akzeptiert. Versuche es erneut oder verwende einen anderen Registrierungslink."; "wallet__onchain_abandon_transfer_note" = "Überweisungen auf dein Ausgabenguthaben sind nicht mehr verfügbar. Bitkit prüft zuerst das Netzwerk und schickt die Coins dieser Überweisung dann zurück in deine Wallet, damit die ursprüngliche Überweisung nie bestätigt wird. Dafür fällt eine Netzwerkgebühr an."; "watch_only_accounts__active_section" = "Aktive Konten"; "watch_only_accounts__copy_xpub" = "xpub kopieren"; @@ -1439,6 +1441,7 @@ "wallet__payment_received" = "Bitcoin empfangen"; "wallet__payment_request" = "Zahlungsanforderung"; "wallet__payment_request_unavailable" = "Die Zahlungsanforderung ist nicht mehr verfügbar."; +"wallet__payment_request_note_too_long" = "Kürze die Notiz und versuche es erneut."; "wallet__payment_request_expired" = "Die Zahlungsanforderung ist abgelaufen."; "wallet__payment_request_waiting_for_details" = "Die Zahlungsdetails werden noch wiederhergestellt. Versuche es in Kürze erneut."; "wallet__payment_request_in_progress" = "Die Zahlungsanforderung wird bereits bearbeitet."; @@ -1777,6 +1780,7 @@ "wallet__payment_request_payment_verified" = "Zahlung verifiziert"; "wallet__payment_request_underpaid" = "Empfangen · Unterbezahlt"; "wallet__payment_request_after_expiry" = "Empfangen · Nach Ablauf"; +"wallet__payment_request_before_request" = "Empfangen · Vor der Anforderung"; "pubky_auth__usdt_intro_description" = "Um zu verdienen, musst du deine USDT-Adresse mit Paykit teilen. Paykit kann Verkaufsaktivitäten sehen, aber kein Guthaben ausgeben."; "pubky_auth__usdt_intro_optional" = "Du kannst deine USDT-Adresse mit Paykit teilen, um Einnahmen zu empfangen. Paykit kann Verkaufsaktivitäten sehen, aber kein Guthaben ausgeben."; "pubky_auth__usdt_unavailable" = "Deine USDT-Adresse ist nicht verfügbar. Bitte versuche es erneut."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 825c42abd..d61213441 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -595,6 +595,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Δημιούργησε μια νέα ταυτότητα Pubky σε αυτόν τον homeserver. Συνέχισε μόνο αν τον εμπιστεύεσαι."; "pubky_auth__approval_failed" = "Η Εξουσιοδότηση Απέτυχε"; +"pubky_auth__identity_unavailable_desc" = "Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά."; +"profile__auth_error_signup_code_rejected" = "Ο homeserver δεν αποδέχτηκε αυτόν τον κωδικό εγγραφής. Δοκίμασε ξανά ή χρησιμοποίησε άλλο σύνδεσμο εγγραφής."; "wallet__onchain_abandon_transfer_note" = "Οι μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Το Bitkit ελέγχει πρώτα το δίκτυο και μετά επιστρέφει τα νομίσματα αυτής της μεταφοράς στο πορτοφόλι σας, ώστε η αρχική μεταφορά να μην επιβεβαιωθεί ποτέ. Αυτό έχει χρέωση δικτύου."; "watch_only_accounts__active_section" = "Ενεργοί λογαριασμοί"; "watch_only_accounts__copy_xpub" = "Αντιγραφή xpub"; @@ -1190,6 +1192,7 @@ "wallet__payment_received" = "Bitcoin Λήφθηκε"; "wallet__payment_request" = "Αίτημα πληρωμής"; "wallet__payment_request_unavailable" = "Το αίτημα πληρωμής δεν είναι πλέον διαθέσιμο."; +"wallet__payment_request_note_too_long" = "Συντόμευσε τη σημείωση και δοκίμασε ξανά."; "wallet__payment_request_expired" = "Το αίτημα πληρωμής έληξε."; "wallet__payment_request_waiting_for_details" = "Τα στοιχεία πληρωμής ανακτώνται ακόμα. Δοκιμάστε ξανά σε λίγο."; "wallet__payment_request_in_progress" = "Το αίτημα πληρωμής βρίσκεται ήδη σε επεξεργασία."; @@ -1511,6 +1514,7 @@ "wallet__payment_request_payment_verified" = "Η πληρωμή επαληθεύτηκε"; "wallet__payment_request_underpaid" = "Ελήφθη · Ελλιπής πληρωμή"; "wallet__payment_request_after_expiry" = "Ελήφθη · Μετά τη λήξη"; +"wallet__payment_request_before_request" = "Ελήφθη · Πριν από το αίτημα"; "pubky_auth__usdt_intro_description" = "Για να κερδίζετε, πρέπει να μοιραστείτε τη διεύθυνση USDT σας με το Paykit. Μπορεί να βλέπει τη δραστηριότητα πωλήσεων, αλλά δεν μπορεί να ξοδέψει κεφάλαια."; "pubky_auth__usdt_intro_optional" = "Μπορείτε να μοιραστείτε τη διεύθυνση USDT σας με το Paykit για να λαμβάνετε τα κέρδη σας. Μπορεί να βλέπει τη δραστηριότητα πωλήσεων, αλλά δεν μπορεί να ξοδέψει κεφάλαια."; "pubky_auth__usdt_unavailable" = "Η διεύθυνση USDT σας δεν είναι διαθέσιμη. Δοκιμάστε ξανά."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index bace84d39..6a7b132fb 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -696,6 +696,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Create a new Pubky identity on this homeserver. Only continue if you trust it."; "pubky_auth__approval_failed" = "Authorization Failed"; +"pubky_auth__identity_unavailable_desc" = "Check your connection and try again."; +"profile__auth_error_signup_code_rejected" = "The homeserver did not accept this signup code. Try again or use another signup link."; "watch_only_accounts__active_section" = "Active accounts"; "watch_only_accounts__copy_xpub" = "Copy xpub"; "watch_only_accounts__description" = "Each approved Paykit server gets a separate Bitcoin account. Turn tracking off to unload an account without deleting its wallet history."; @@ -1453,6 +1455,7 @@ "wallet__payment_received" = "Received Bitcoin"; "wallet__payment_request" = "Payment Request"; "wallet__payment_request_unavailable" = "The payment request is no longer available."; +"wallet__payment_request_note_too_long" = "Shorten the note and try again."; "wallet__payment_request_expired" = "The payment request has expired."; "wallet__payment_request_waiting_for_details" = "Payment details are still being recovered. Try again shortly."; "wallet__payment_request_in_progress" = "The payment request is already being processed."; @@ -1897,6 +1900,7 @@ "wallet__payment_request_underpaid" = "Received · Underpaid"; "wallet__payment_request_after_expiry" = "Received · After expiry"; +"wallet__payment_request_before_request" = "Received · Before request"; "pubky_auth__usdt_intro_description" = "To earn, you need to share your USDT address with Paykit. It can view sales activity, but cannot spend funds."; "pubky_auth__usdt_intro_optional" = "You can share your USDT address with Paykit to receive earnings. It can view sales activity, but cannot spend funds."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 4efede781..34f87b47c 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -709,6 +709,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Cree una nueva identidad Pubky en este homeserver. Continúe solo si confía en él."; "pubky_auth__approval_failed" = "Falló la autorización"; +"pubky_auth__identity_unavailable_desc" = "Revise su conexión y vuelva a intentarlo."; +"profile__auth_error_signup_code_rejected" = "El homeserver no aceptó este código de registro. Vuelva a intentarlo o use otro enlace de registro."; "wallet__onchain_abandon_transfer_note" = "Las transferencias al saldo de gasto ya no están disponibles. Bitkit primero revisa la red y luego devuelve las monedas de esta transferencia a tu billetera para que la transferencia original nunca se confirme. Esto tiene una comisión de red."; "watch_only_accounts__active_section" = "Cuentas activas"; "watch_only_accounts__copy_xpub" = "Copiar xpub"; @@ -1444,6 +1446,7 @@ "wallet__payment_received" = "Bitcoin recibido"; "wallet__payment_request" = "Solicitud de pago"; "wallet__payment_request_unavailable" = "La solicitud de pago ya no está disponible."; +"wallet__payment_request_note_too_long" = "Acorte la nota y vuelva a intentarlo."; "wallet__payment_request_expired" = "La solicitud de pago venció."; "wallet__payment_request_waiting_for_details" = "Los datos de pago aún se están recuperando. Vuelva a intentarlo en breve."; "wallet__payment_request_in_progress" = "La solicitud de pago ya se está procesando."; @@ -1783,6 +1786,7 @@ "wallet__payment_request_payment_verified" = "Pago verificado"; "wallet__payment_request_underpaid" = "Recibido · Pago insuficiente"; "wallet__payment_request_after_expiry" = "Recibido · Tras vencer"; +"wallet__payment_request_before_request" = "Recibido · Antes de la solicitud"; "pubky_auth__usdt_intro_description" = "Para ganar, debe compartir su dirección USDT con Paykit. Puede ver la actividad de ventas, pero no puede gastar fondos."; "pubky_auth__usdt_intro_optional" = "Puede compartir su dirección USDT con Paykit para recibir sus ganancias. Paykit podrá ver la actividad de ventas, pero no gastar fondos."; "pubky_auth__usdt_unavailable" = "Su dirección USDT no está disponible. Vuelva a intentarlo."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index f6e401390..874dd2b49 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -655,6 +655,8 @@ "pubky_auth__homeserver" = "Servidor principal"; "pubky_auth__signup_description" = "Cree una nueva identidad Pubky en este servidor principal. Continúe solo si confía en él."; "pubky_auth__approval_failed" = "Error de autorización"; +"pubky_auth__identity_unavailable_desc" = "Compruebe su conexión e inténtelo de nuevo."; +"profile__auth_error_signup_code_rejected" = "El servidor principal no ha aceptado este código de registro. Inténtelo de nuevo o use otro enlace de registro."; "wallet__onchain_abandon_transfer_note" = "Las transferencias al saldo de gasto ya no están disponibles. Bitkit primero comprueba la red y después devuelve las monedas de esta transferencia a tu billetera para que la transferencia original nunca se confirme. Esto tiene una comisión de red."; "watch_only_accounts__active_section" = "Cuentas activas"; "watch_only_accounts__copy_xpub" = "Copiar xpub"; @@ -1336,6 +1338,7 @@ "wallet__payment_received" = "Bitcoin recibido"; "wallet__payment_request" = "Solicitud de pago"; "wallet__payment_request_unavailable" = "La solicitud de pago ya no está disponible."; +"wallet__payment_request_note_too_long" = "Acorte la nota e inténtelo de nuevo."; "wallet__payment_request_expired" = "La solicitud de pago ha caducado."; "wallet__payment_request_waiting_for_details" = "Los datos de pago aún se están recuperando. Inténtelo de nuevo en breve."; "wallet__payment_request_in_progress" = "La solicitud de pago ya se está procesando."; @@ -1660,6 +1663,7 @@ "wallet__payment_request_payment_verified" = "Pago verificado"; "wallet__payment_request_underpaid" = "Recibido · Pago insuficiente"; "wallet__payment_request_after_expiry" = "Recibido · Tras caducar"; +"wallet__payment_request_before_request" = "Recibido · Antes de la solicitud"; "pubky_auth__usdt_intro_description" = "Para ganar, necesita compartir con Paykit su dirección USDT. Puede ver la actividad de ventas, pero no puede gastar fondos."; "pubky_auth__usdt_intro_optional" = "Puede compartir con Paykit su dirección USDT para recibir sus ganancias. Paykit podrá ver la actividad de ventas, pero no gastar fondos."; "pubky_auth__usdt_unavailable" = "Su dirección USDT no está disponible. Inténtelo de nuevo."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 4e7711046..0952bf0ff 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -709,6 +709,8 @@ "pubky_auth__homeserver" = "Serveur domestique"; "pubky_auth__signup_description" = "Créez une nouvelle identité Pubky sur ce serveur domestique. Ne continuez que si vous lui faites confiance."; "pubky_auth__approval_failed" = "Échec de l'autorisation"; +"pubky_auth__identity_unavailable_desc" = "Vérifiez votre connexion et réessayez."; +"profile__auth_error_signup_code_rejected" = "Le serveur domestique n'a pas accepté ce code d'inscription. Réessayez ou utilisez un autre lien d'inscription."; "wallet__onchain_abandon_transfer_note" = "Les transferts vers le solde de dépenses ne sont plus disponibles. Bitkit vérifie d'abord le réseau, puis renvoie les pièces de ce transfert vers votre portefeuille afin que le transfert d'origine ne soit jamais confirmé. Cela coûte des frais de réseau."; "watch_only_accounts__active_section" = "Comptes actifs"; "watch_only_accounts__copy_xpub" = "Copier la xpub"; @@ -1452,6 +1454,7 @@ "wallet__payment_received" = "Bitcoin reçus"; "wallet__payment_request" = "Demande de paiement"; "wallet__payment_request_unavailable" = "La demande de paiement n'est plus disponible."; +"wallet__payment_request_note_too_long" = "Raccourcissez la note et réessayez."; "wallet__payment_request_expired" = "La demande de paiement a expiré."; "wallet__payment_request_waiting_for_details" = "Les informations de paiement sont encore en cours de récupération. Réessayez sous peu."; "wallet__payment_request_in_progress" = "La demande de paiement est déjà en cours de traitement."; @@ -1791,6 +1794,7 @@ "wallet__payment_request_payment_verified" = "Paiement vérifié"; "wallet__payment_request_underpaid" = "Reçu · Insuffisant"; "wallet__payment_request_after_expiry" = "Reçu · Après expiration"; +"wallet__payment_request_before_request" = "Reçu · Avant la demande"; "pubky_auth__usdt_intro_description" = "Pour gagner, vous devez partager votre adresse USDT avec Paykit. Il peut voir l'activité des ventes, mais ne peut pas dépenser de fonds."; "pubky_auth__usdt_intro_optional" = "Vous pouvez partager votre adresse USDT avec Paykit pour recevoir vos gains. Il peut voir l'activité des ventes, mais ne peut pas dépenser de fonds."; "pubky_auth__usdt_unavailable" = "Votre adresse USDT est indisponible. Veuillez réessayer."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 818e6a0f8..4b9336103 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -689,6 +689,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Crea una nuova identità Pubky su questo homeserver. Continua solo se ti fidi."; "pubky_auth__approval_failed" = "Autorizzazione Non Riuscita"; +"pubky_auth__identity_unavailable_desc" = "Controlla la connessione e riprova."; +"profile__auth_error_signup_code_rejected" = "L'homeserver non ha accettato questo codice di registrazione. Riprova o usa un altro link di registrazione."; "wallet__onchain_abandon_transfer_note" = "I trasferimenti al saldo di spesa non sono più disponibili. Bitkit controlla prima la rete, poi rimanda le monete di questo trasferimento al tuo wallet, così il trasferimento originale non potrà mai essere confermato. Questo costa una commissione di rete."; "watch_only_accounts__active_section" = "Account attivi"; "watch_only_accounts__copy_xpub" = "Copia xpub"; @@ -1414,6 +1416,7 @@ "wallet__payment_received" = "Bitcoin ricevuti"; "wallet__payment_request" = "Richiesta di pagamento"; "wallet__payment_request_unavailable" = "La richiesta di pagamento non è più disponibile."; +"wallet__payment_request_note_too_long" = "Accorcia la nota e riprova."; "wallet__payment_request_expired" = "La richiesta di pagamento è scaduta."; "wallet__payment_request_waiting_for_details" = "I dati di pagamento sono ancora in fase di recupero. Riprova a breve."; "wallet__payment_request_in_progress" = "La richiesta di pagamento è già in elaborazione."; @@ -1750,6 +1753,7 @@ "wallet__payment_request_payment_verified" = "Pagamento verificato"; "wallet__payment_request_underpaid" = "Ricevuto · Insufficiente"; "wallet__payment_request_after_expiry" = "Ricevuto · Dopo la scadenza"; +"wallet__payment_request_before_request" = "Ricevuto · Prima della richiesta"; "pubky_auth__usdt_intro_description" = "Per guadagnare, devi condividere con Paykit il tuo indirizzo USDT. Può vedere l'attività di vendita, ma non può spendere fondi."; "pubky_auth__usdt_intro_optional" = "Puoi condividere con Paykit il tuo indirizzo USDT per ricevere i guadagni. Può vedere l'attività di vendita, ma non può spendere fondi."; "pubky_auth__usdt_unavailable" = "Il tuo indirizzo USDT non è disponibile. Riprova."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 3aad80411..440f8b961 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -706,6 +706,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Maak een nieuwe Pubky-identiteit aan op deze homeserver. Ga alleen verder als u deze vertrouwt."; "pubky_auth__approval_failed" = "Autorisatie mislukt"; +"pubky_auth__identity_unavailable_desc" = "Controleer uw verbinding en probeer het opnieuw."; +"profile__auth_error_signup_code_rejected" = "De homeserver heeft deze aanmeldcode niet geaccepteerd. Probeer het opnieuw of gebruik een andere aanmeldlink."; "wallet__onchain_abandon_transfer_note" = "Overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Bitkit controleert eerst het netwerk en stuurt de munten van deze overboeking dan terug naar je wallet, zodat de oorspronkelijke overboeking nooit bevestigd wordt. Dit kost netwerkkosten."; "watch_only_accounts__active_section" = "Actieve accounts"; "watch_only_accounts__copy_xpub" = "xpub kopiëren"; @@ -1447,6 +1449,7 @@ "wallet__payment_received" = "Bitcoin Ontvangen"; "wallet__payment_request" = "Betaalverzoek"; "wallet__payment_request_unavailable" = "Het betaalverzoek is niet meer beschikbaar."; +"wallet__payment_request_note_too_long" = "Maak de opmerking korter en probeer het opnieuw."; "wallet__payment_request_expired" = "Het betaalverzoek is verlopen."; "wallet__payment_request_waiting_for_details" = "Betaalgegevens worden nog hersteld. Probeer het zo opnieuw."; "wallet__payment_request_in_progress" = "Het betaalverzoek wordt al verwerkt."; @@ -1785,6 +1788,7 @@ "wallet__payment_request_payment_verified" = "Betaling geverifieerd"; "wallet__payment_request_underpaid" = "Ontvangen · Onderbetaald"; "wallet__payment_request_after_expiry" = "Ontvangen · Na vervaldatum"; +"wallet__payment_request_before_request" = "Ontvangen · Vóór het verzoek"; "pubky_auth__usdt_intro_description" = "Om te verdienen, moet u uw USDT-adres delen met Paykit. Dit kan verkoopactiviteit zien, maar kan geen geld uitgeven."; "pubky_auth__usdt_intro_optional" = "U kunt uw USDT-adres delen met Paykit om inkomsten te ontvangen. Dit kan verkoopactiviteit zien, maar kan geen geld uitgeven."; "pubky_auth__usdt_unavailable" = "Uw USDT-adres is niet beschikbaar. Probeer het opnieuw."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index c02d5358b..8a67c9ccb 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -708,6 +708,8 @@ "pubky_auth__homeserver" = "Serwer domowy"; "pubky_auth__signup_description" = "Utwórz nową tożsamość Pubky na tym serwerze domowym. Kontynuuj tylko, jeśli mu ufasz."; "pubky_auth__approval_failed" = "Autoryzacja nie powiodła się"; +"pubky_auth__identity_unavailable_desc" = "Sprawdź połączenie i spróbuj ponownie."; +"profile__auth_error_signup_code_rejected" = "Serwer domowy nie zaakceptował tego kodu rejestracyjnego. Spróbuj ponownie lub użyj innego linku rejestracyjnego."; "wallet__onchain_abandon_transfer_note" = "Przelewy na saldo wydatków nie są już dostępne. Bitkit najpierw sprawdza sieć, a potem odsyła monety z tego przelewu do Twojego portfela, aby pierwotny przelew nigdy nie został potwierdzony. Wiąże się to z opłatą sieciową."; "watch_only_accounts__active_section" = "Aktywne konta"; "watch_only_accounts__copy_xpub" = "Kopiuj xpub"; @@ -1451,6 +1453,7 @@ "wallet__payment_received" = "Otrzymano Bitcoinów"; "wallet__payment_request" = "Żądanie płatności"; "wallet__payment_request_unavailable" = "Żądanie płatności nie jest już dostępne."; +"wallet__payment_request_note_too_long" = "Skróć notatkę i spróbuj ponownie."; "wallet__payment_request_expired" = "Żądanie płatności wygasło."; "wallet__payment_request_waiting_for_details" = "Dane płatności są wciąż odzyskiwane. Spróbuj ponownie za chwilę."; "wallet__payment_request_in_progress" = "Żądanie płatności jest już przetwarzane."; @@ -1790,6 +1793,7 @@ "wallet__payment_request_payment_verified" = "Płatność zweryfikowana"; "wallet__payment_request_underpaid" = "Otrzymano · Niedopłata"; "wallet__payment_request_after_expiry" = "Otrzymano · Po terminie"; +"wallet__payment_request_before_request" = "Otrzymano · Przed żądaniem"; "pubky_auth__usdt_intro_description" = "Aby zarabiać, musisz udostępnić Paykit swój adres USDT. Paykit może wyświetlać aktywność sprzedaży, ale nie może wydawać środków."; "pubky_auth__usdt_intro_optional" = "Możesz udostępnić Paykit swój adres USDT, aby otrzymywać zarobki. Paykit może wyświetlać aktywność sprzedaży, ale nie może wydawać środków."; "pubky_auth__usdt_unavailable" = "Twój adres USDT jest niedostępny. Spróbuj ponownie."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index fb2049f2a..3051d7554 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -708,6 +708,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Crie uma nova identidade Pubky neste homeserver. Só continue se você confiar nele."; "pubky_auth__approval_failed" = "Falha na Autorização"; +"pubky_auth__identity_unavailable_desc" = "Verifique sua conexão e tente novamente."; +"profile__auth_error_signup_code_rejected" = "O homeserver não aceitou este código de cadastro. Tente novamente ou use outro link de cadastro."; "wallet__onchain_abandon_transfer_note" = "As transferências para o saldo de gastos não estão mais disponíveis. O Bitkit primeiro verifica a rede e depois devolve as moedas desta transferência para sua carteira, para que a transferência original nunca seja confirmada. Isso tem uma taxa de rede."; "watch_only_accounts__active_section" = "Contas ativas"; "watch_only_accounts__copy_xpub" = "Copiar xpub"; @@ -1452,6 +1454,7 @@ "wallet__payment_received" = "Bitcoin Recebido"; "wallet__payment_request" = "Solicitação de pagamento"; "wallet__payment_request_unavailable" = "A solicitação de pagamento não está mais disponível."; +"wallet__payment_request_note_too_long" = "Encurte a nota e tente novamente."; "wallet__payment_request_expired" = "A solicitação de pagamento expirou."; "wallet__payment_request_waiting_for_details" = "Os dados de pagamento ainda estão sendo recuperados. Tente novamente em breve."; "wallet__payment_request_in_progress" = "A solicitação de pagamento já está sendo processada."; @@ -1791,6 +1794,7 @@ "wallet__payment_request_payment_verified" = "Pagamento verificado"; "wallet__payment_request_underpaid" = "Recebido · Pago a menor"; "wallet__payment_request_after_expiry" = "Recebido · Após o vencimento"; +"wallet__payment_request_before_request" = "Recebido · Antes da solicitação"; "pubky_auth__usdt_intro_description" = "Para ganhar, você precisa compartilhar seu endereço USDT com o Paykit. Ele pode ver a atividade de vendas, mas não pode gastar fundos."; "pubky_auth__usdt_intro_optional" = "Você pode compartilhar seu endereço USDT com o Paykit para receber ganhos. Ele pode ver a atividade de vendas, mas não pode gastar fundos."; "pubky_auth__usdt_unavailable" = "Seu endereço USDT não está disponível. Tente novamente."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index e9a82446c..c04fea233 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -612,6 +612,8 @@ "pubky_auth__homeserver" = "Homeserver"; "pubky_auth__signup_description" = "Crie uma nova identidade Pubky neste homeserver. Continue apenas se confiar nele."; "pubky_auth__approval_failed" = "Falha na autorização"; +"pubky_auth__identity_unavailable_desc" = "Verifique a ligação e tente novamente."; +"profile__auth_error_signup_code_rejected" = "O homeserver não aceitou este código de registo. Tente novamente ou utilize outra ligação de registo."; "watch_only_accounts__active_section" = "Contas ativas"; "watch_only_accounts__copy_xpub" = "Copiar xpub"; "watch_only_accounts__description" = "Cada servidor Paykit aprovado recebe uma conta Bitcoin separada. Desative o acompanhamento para descarregar uma conta sem eliminar o histórico da carteira."; @@ -1146,6 +1148,7 @@ "wallet__boost_swipe" = "Arraste para acelerar"; "wallet__payment_received" = "Bitcoin Recebido"; "wallet__payment_request_unavailable" = "O pedido de pagamento já não está disponível."; +"wallet__payment_request_note_too_long" = "Encurte a nota e tente novamente."; "wallet__payment_request_expired" = "O pedido de pagamento expirou."; "wallet__payment_request_waiting_for_details" = "Os dados de pagamento ainda estão a ser recuperados. Tente novamente daqui a pouco."; "wallet__payment_request_in_progress" = "O pedido de pagamento já está a ser processado."; @@ -1437,6 +1440,7 @@ "wallet__payment_request_payment_verified" = "Pagamento verificado"; "wallet__payment_request_underpaid" = "Recebido · Pago a menos"; "wallet__payment_request_after_expiry" = "Recebido · Após expirar"; +"wallet__payment_request_before_request" = "Recebido · Antes do pedido"; "pubky_auth__usdt_intro_description" = "Para ganhar, tem de partilhar o seu endereço USDT com o Paykit. Este pode ver a atividade de vendas, mas não pode gastar fundos."; "pubky_auth__usdt_intro_optional" = "Pode partilhar o seu endereço USDT com o Paykit para receber ganhos. Este pode ver a atividade de vendas, mas não pode gastar fundos."; "pubky_auth__usdt_unavailable" = "O seu endereço USDT não está disponível. Tente novamente."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index e9499eaae..06cfb4fbd 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -703,6 +703,8 @@ "pubky_auth__homeserver" = "Домашний сервер"; "pubky_auth__signup_description" = "Создайте новый идентификатор Pubky на этом домашнем сервере. Продолжайте, только если доверяете ему."; "pubky_auth__approval_failed" = "Ошибка Авторизации"; +"pubky_auth__identity_unavailable_desc" = "Проверьте подключение и попробуйте снова."; +"profile__auth_error_signup_code_rejected" = "Домашний сервер не принял этот код регистрации. Попробуйте снова или используйте другую ссылку для регистрации."; "wallet__onchain_abandon_transfer_note" = "Переводы на баланс расходов больше недоступны. Bitkit сначала проверяет сеть, а затем возвращает монеты этого перевода в ваш кошелёк, чтобы исходный перевод никогда не подтвердился. За это взимается сетевая комиссия."; "watch_only_accounts__active_section" = "Активные аккаунты"; "watch_only_accounts__copy_xpub" = "Копировать xpub"; @@ -1435,6 +1437,7 @@ "wallet__payment_received" = "Получен Биткоин"; "wallet__payment_request" = "Запрос на оплату"; "wallet__payment_request_unavailable" = "Запрос на оплату больше недоступен."; +"wallet__payment_request_note_too_long" = "Сократите заметку и попробуйте снова."; "wallet__payment_request_expired" = "Срок действия запроса на оплату истёк."; "wallet__payment_request_waiting_for_details" = "Платёжные данные ещё восстанавливаются. Попробуйте чуть позже."; "wallet__payment_request_in_progress" = "Запрос на оплату уже обрабатывается."; @@ -1771,6 +1774,7 @@ "wallet__payment_request_payment_verified" = "Платёж подтверждён"; "wallet__payment_request_underpaid" = "Получено · Недоплата"; "wallet__payment_request_after_expiry" = "Получено · После истечения"; +"wallet__payment_request_before_request" = "Получено · До запроса"; "pubky_auth__usdt_intro_description" = "Чтобы зарабатывать, нужно предоставить Paykit ваш адрес USDT. Paykit может видеть активность продаж, но не может тратить средства."; "pubky_auth__usdt_intro_optional" = "Вы можете предоставить Paykit ваш адрес USDT, чтобы получать заработок. Paykit может видеть активность продаж, но не может тратить средства."; "pubky_auth__usdt_unavailable" = "Ваш адрес USDT недоступен. Попробуйте ещё раз."; diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 39087cb41..0115e5627 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -988,6 +988,20 @@ actor PaykitAllowanceExecutor { let endpointIdentifier = payment.endpoint.methodId.rawValue let paymentAppId = try payment.context.paymentAppId(for: endpointIdentifier) + do { + try PaykitPaymentProofSize.validate( + paymentReference: request.paymentReference, + paymentAppId: paymentAppId, + paymentEndpointIdentifier: endpointIdentifier, + billingPeriod: request.billingPeriod?.sdkValue, + allowanceId: candidate.allowanceId, + conversionQuoteId: try? request.payment(using: payment.endpoint.methodId, at: now()).quoteId, + kind: .lightning + ) + } catch { + Logger.info("An incoming request's payment proof cannot be sent in one message; it stays on the manual flow", context: "PaykitAllowance") + return .manual + } // The SDK requires this app to hold the shared execution claim before it queues an automatic Acceptance. try await payer.claimForExecution(request) let recordedAcceptanceIntent = try await payer.recordAcceptanceIntent(request, identity: identity) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 94037be5f..3b74a2d55 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -16,6 +16,61 @@ enum PaykitPaymentProofKind: String, Codable { } } +/// Checks that the Payment Proof Event Message a payment would send fits one Paykit message, so a request whose proof +/// could never be delivered is refused before anything is paid. +enum PaykitPaymentProofSize { + /// The largest log index an ERC-20 receipt can carry, the 78-digit maximum of a uint256. + static let longestUsdtLogIndex = "115792089237316195423570985008687907853269984665640564039457584007913129639935" + + /// The longest proof each rail produces: a 32-byte preimage or txid, or an ERC-20 proof with the largest log index. + static func placeholderProof(for kind: PaykitPaymentProofKind) throws -> String { + let values: [String: String] = switch kind { + case .lightning, .onchain: + ["data": String(repeating: "0", count: 64), "type": kind.rawValue] + case .usdt: + [ + "type": kind.rawValue, + "chain_id": PaykitUsdt.chainId, + "transaction_hash": "0x" + String(repeating: "0", count: 64), + "receipt_log_index": longestUsdtLogIndex, + "signature": "0x" + String(repeating: "0", count: 130), + ] + } + return try String(decoding: JSONSerialization.data(withJSONObject: values, options: [.sortedKeys]), as: UTF8.self) + } + + static func validate( + paymentReference: String, + paymentAppId: String, + paymentEndpointIdentifier: String, + billingPeriod: Paykit.BillingPeriod?, + allowanceId: String?, + conversionQuoteId: String?, + kind: PaykitPaymentProofKind + ) throws { + let length: UInt32 + do { + length = try Paykit.paymentProofEventLen( + localAppId: PaykitSdkService.appId, + paymentReference: Paykit.PaymentReference(text: paymentReference), + submission: Paykit.PaymentProofSubmission( + billingPeriod: billingPeriod, + paymentAppId: paymentAppId, + paymentEndpointIdentifier: paymentEndpointIdentifier, + allowanceId: allowanceId, + conversionQuoteId: conversionQuoteId, + proof: Paykit.PrivateJsonObject(text: placeholderProof(for: kind)) + ) + ) + } catch { + throw PaykitPaymentRequestError.requestUnavailable + } + guard Int(length) <= PaykitSdkService.maximumMessageBytes else { + throw PaykitPaymentRequestError.requestUnavailable + } + } +} + struct PaykitOnchainPaymentResolution: Hashable { let identity: String let requestId: PaykitPaymentRequest.ID @@ -394,6 +449,15 @@ actor PaykitPaymentProofService { proof.onchainAmountSats = payment.amount.atomic } proof.allowanceId = allowanceId + try PaykitPaymentProofSize.validate( + paymentReference: request.paymentReference, + paymentAppId: proof.paymentAppId, + paymentEndpointIdentifier: proof.paymentEndpointIdentifier, + billingPeriod: proof.billingPeriod?.sdkValue, + allowanceId: proof.allowanceId, + conversionQuoteId: proof.conversionQuoteId, + kind: kind + ) try await mutationLock.withLock { try await prepareLocked(request: request, proof: proof) } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 4a319d888..723ad1cbd 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -595,6 +595,7 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { case operationInProgress case amountMismatch case subscriptionTooLong + case noteTooLong var errorDescription: String? { switch self { @@ -608,6 +609,8 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { t("wallet__payment_request_mismatch") case .subscriptionTooLong: t("subscriptions__content_too_long") + case .noteTooLong: + t("wallet__payment_request_note_too_long") } } } @@ -907,10 +910,11 @@ struct PaykitPaymentRequestService { paymentDeadline: .at(timestamp: Self.timestamp(draft.expiresAt)), metadata: Paykit.PrivateJsonObject(text: metadataText) ) - let record = try await sdk.proposePaymentRequest( - counterparty: target.publicKey, + let record = try await proposePaymentRequest( + to: target, terms: terms, - expectedIdentity: expectedIdentity + expectedIdentity: expectedIdentity, + tooLarge: .noteTooLong ) let wasSent = await proposalWasSent(record, to: target.publicKey, expectedIdentity: expectedIdentity) let deliveryStatus = wasSent ? PaykitPaymentRequest.DeliveryStatus.sent : .queued @@ -991,10 +995,11 @@ struct PaykitPaymentRequestService { proposalDate: proposalDate ) try PaykitSubscriptionProposal.validate(terms) - let record = try await sdk.proposePaymentRequest( - counterparty: target.publicKey, + let record = try await proposePaymentRequest( + to: target, terms: terms, - expectedIdentity: expectedIdentity + expectedIdentity: expectedIdentity, + tooLarge: .subscriptionTooLong ) let deliveryStatus = await proposalWasSent(record, to: target.publicKey, expectedIdentity: expectedIdentity) ? PaykitPaymentRequest.DeliveryStatus.sent @@ -1005,6 +1010,22 @@ struct PaykitPaymentRequestService { return subscription } + /// The SDK refuses a proposal that does not fit one Paykit message before queueing anything. + private func proposePaymentRequest( + to target: PaykitPaymentRequestTarget, + terms: Paykit.PaymentRequestTerms, + expectedIdentity: String, + tooLarge: PaykitPaymentRequestError + ) async throws -> Paykit.PaymentRequestRecord { + do { + return try await sdk.proposePaymentRequest(counterparty: target.publicKey, terms: terms, expectedIdentity: expectedIdentity) + } catch let PaykitError.Protocol(code, _) where code == Self.messageTooLargeCode { + throw tooLarge + } + } + + static let messageTooLargeCode = "message_too_large" + private func subscriptionTerms( _ draft: PaykitSubscriptionDraft, iconURI: String?, diff --git a/Bitkit/Services/PaykitUsdtPaymentService.swift b/Bitkit/Services/PaykitUsdtPaymentService.swift index 537954cb2..872608bed 100644 --- a/Bitkit/Services/PaykitUsdtPaymentService.swift +++ b/Bitkit/Services/PaykitUsdtPaymentService.swift @@ -61,11 +61,22 @@ final class PaykitUsdtPaymentService { let receivedAt: Date let underpaid: Bool let afterExpiry: Bool + /// Transferred before this wallet recorded its request, so it does not pay the request. + var beforeRequest: Bool? = false var satisfied: Bool { - verified && !underpaid && !afterExpiry + verified && !underpaid && !afterExpiry && beforeRequest != true } } + /// Arbitrum's sequencer may set a block timestamp up to a day behind real time. + static let transferTimeTolerance: TimeInterval = 24 * 60 * 60 + + /// A transfer is checked against the time this wallet stored its proposal, not the payer's own times. + static func isBeforeRequest(receivedAt: Date, proposalRecordedAt: String?) -> Bool { + guard let recordedAt = proposalRecordedAt.flatMap(PaykitPaymentRequest.parseDate) else { return false } + return receivedAt < recordedAt.addingTimeInterval(-transferTimeTolerance) + } + private struct State: Codable { var attempts: [Attempt] = [] var receipts: [Receipt] = [] @@ -307,26 +318,17 @@ final class PaykitUsdtPaymentService { /// Check the complete wire message, including its outer request fields, before spending funds. static func validateProofSize(binding: UsdtPaymentProofBinding) throws { - let placeholder = Proof(UsdtPaymentProof(chainId: "42161", transactionHash: "0x" + String(repeating: "0", count: 64), - receiptLogIndex: String(repeating: "9", count: 78), - signature: "0x" + String(repeating: "0", count: 130))) - var message: [String: Any] = try [ - "version": 1, "kind": "paykit.payment_proof", "app_id": "bitkit", "event_id": UUID().uuidString.lowercased(), - "payment_request_id": binding.paymentRequestId, "payment_reference": binding.paymentReference, - "payment_app_id": binding.paymentAppId, - "payment_endpoint_identifier": binding.paymentEndpointIdentifier, - "billing_period": NSNull(), "proof": JSONSerialization.jsonObject(with: JSONEncoder().encode(placeholder)), - ] - if !binding.periodStartsAt.isEmpty { - message["billing_period"] = ["starts_at": binding.periodStartsAt, "ends_at": binding.periodEndsAt] - } - if !binding.conversionQuoteId.isEmpty { - message["conversion_quote_id"] = binding.conversionQuoteId - } - guard try JSONSerialization.data(withJSONObject: message, options: [.withoutEscapingSlashes]).count <= PaykitSdkService.maximumMessageBytes - else { - throw PaykitPaymentRequestError.requestUnavailable - } + try PaykitPaymentProofSize.validate( + paymentReference: binding.paymentReference, + paymentAppId: binding.paymentAppId, + paymentEndpointIdentifier: binding.paymentEndpointIdentifier, + billingPeriod: binding.periodStartsAt.isEmpty + ? nil + : Paykit.BillingPeriod(startsAt: binding.periodStartsAt, endsAt: binding.periodEndsAt), + allowanceId: nil, + conversionQuoteId: binding.conversionQuoteId.isEmpty ? nil : binding.conversionQuoteId, + kind: .usdt + ) } func prepare(context: ContactPaymentContext, quote: UsdtQuote, wallet: UsdtWalletManager, @@ -582,7 +584,9 @@ final class PaykitUsdtPaymentService { paymentId: payment.paymentId, proofEventId: submission.eventId, verified: true, transferId: payment.transferId, amount: received, receivedAt: receivedAt, underpaid: received.atomic < terms.amount.atomic, - afterExpiry: !terms.isValid(at: receivedAt)) + afterExpiry: !terms.isValid(at: receivedAt), + beforeRequest: Self.isBeforeRequest(receivedAt: receivedAt, + proposalRecordedAt: record.proposalRecordedAt)) guard !current.receipts.contains(receipt) else { continue } current.receipts.removeAll { $0.wallet == owner && $0.identity == identity && $0.paymentId == payment.paymentId } current.receipts.append(receipt) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 23bad5f04..a19399c70 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -447,6 +447,8 @@ actor PaykitSdkService { static let shared = PaykitSdkService() /// Maximum plaintext size accepted by Paykit's pubky-noise transport. static let maximumMessageBytes = 1000 + /// The Paykit App ID this SDK sends from. + static let appId = "bitkit" private static let walletBackupDataChangedSubject = PassthroughSubject() nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { @@ -2036,7 +2038,7 @@ actor PaykitSdkService { } private nonisolated static func config() throws -> PaykitSdkConfig { - var config = try Paykit.defaultConfig(appId: "bitkit") + var config = try Paykit.defaultConfig(appId: appId) config.publicContactSharing = .privateOnly return config } diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index ff0493f44..c17d8ed4c 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -774,6 +774,7 @@ private extension PaykitPaymentRequest { private extension PaykitUsdtPaymentService.Receipt { var statusText: String { let key = !verified ? "wallet__payment_request_status_pending" : + beforeRequest == true ? "wallet__payment_request_before_request" : underpaid ? "wallet__payment_request_underpaid" : afterExpiry ? "wallet__payment_request_after_expiry" : "wallet__payment_request_payment_verified" return t(key) + " · " + usdtExactAmount(amount.atomic) + " USDT" diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index ac561374e..5de86a668 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -1,3 +1,4 @@ +import Paykit import SwiftUI // MARK: - Config & Sheet Item @@ -45,6 +46,18 @@ func pubkyAuthLiteralText(_ value: String) -> String { } } +/// A homeserver that refuses the signup code and a failed connection get their own messages, since retrying is safe for both. +func pubkyAuthApprovalFailureDescription(_ error: Error) -> String { + switch error { + case let PaykitError.Identity(code, _) where code == "signup_code_rejected": + t("profile__auth_error_signup_code_rejected") + case PaykitError.Transport: + t("pubky_auth__identity_unavailable_desc") + default: + error.localizedDescription + } +} + /// Fills each `{placeholder}` from the template only, so request data that itself contains a placeholder stays as it is. func pubkyAuthFilledText(_ template: String, variables: [String: String]) -> String { var result = "" @@ -707,7 +720,7 @@ struct PubkyAuthApprovalSheet: View { } else { Logger.error("Failed to approve pubky auth: \(error)", context: "PubkyAuthApprovalSheet") } - app.toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) + app.toast(type: .error, title: t("pubky_auth__approval_failed"), description: pubkyAuthApprovalFailureDescription(error)) state = .authorize } } diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 0be8dfc8b..63033ef52 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -970,7 +970,7 @@ final class ContactPaymentsServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { _ in writes.append("private:published") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) let reachedHeldPoint = expectation(description: "\(testCase.name): publication reached its held point") diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index f11ea1c79..47fab1e03 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -24,6 +24,7 @@ final class HwFundingSignerTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -40,6 +41,7 @@ final class HwFundingSignerTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 47e0e65fe..98e46daf6 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -97,6 +97,22 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(isHandling) } + func testRequestWhoseProofCannotFitOneMessageStaysManualWithoutAcceptance() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest(paymentReference: String(repeating: "💜", count: 256)) + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let log = harness.log.entries + XCTAssertTrue(log.contains("resolve")) + XCTAssertFalse(log.contains("claimForExecution")) + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("payLightning")) + let intents = await harness.payer.acceptanceIntents + XCTAssertTrue(intents.isEmpty) + } + func testRequestWaitsWithoutAcceptanceWhileThePayeesPaymentListIsPending() async throws { let harness = AllowanceHarness() await harness.payer.setResolveError(PaykitAllowanceError.paymentListPending) diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 4ad7e4559..64c999634 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -654,8 +654,8 @@ enum PaykitAllowanceFixtures { static let identityKey = "pubky\(String(repeating: "z", count: 52))" static let counterpartyKey = "pubky\(String(repeating: "y", count: 52))" static let otherCounterpartyKey = "pubky\(String(repeating: "x", count: 52))" - static let walletAllowanceId = "allowance-wallet" - static let otherAllowanceId = "allowance-other" + static let walletAllowanceId = "30000000-0000-4000-8000-000000000000" + static let otherAllowanceId = "20000000-0000-4000-8000-000000000000" static let lightningIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue static let onchainIdentifier = PublicPaykitService.MethodId.bitcoinOnchainP2wpkh.rawValue static let now = utc("2026-09-24T12:00:00Z") @@ -864,7 +864,8 @@ enum PaykitAllowanceFixtures { asset: String = PaykitIssuerInterop.bitcoinAsset, conversion: Paykit.PaymentConversion? = nil, createdAt: String = "2026-09-24T11:00:00Z", - acceptedEndpoints: [String] = [lightningIdentifier] + acceptedEndpoints: [String] = [lightningIdentifier], + paymentReference: String = "invoice-123" ) throws -> PaykitPaymentRequest { let record = try Paykit.PaymentRequestRecord( counterparty: counterparty, @@ -876,11 +877,12 @@ enum PaykitAllowanceFixtures { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: Paykit.PaymentRequestTerms( amount: Paykit.PaymentRequestAmount(value: amount, asset: asset), - paymentReference: Paykit.PaymentReference(text: "invoice-123"), + paymentReference: Paykit.PaymentReference(text: paymentReference), proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: acceptedEndpoints, @@ -892,6 +894,7 @@ enum PaykitAllowanceFixtures { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 4639062c7..a713ca585 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -77,9 +77,9 @@ final class PaykitContactLifecycleTests: XCTestCase { counterparty: sdk.publicKey, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: testCase.role, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: nil, proposalAppId: testCase.appId, + proposalEventId: nil, proposalAppId: testCase.appId, proposalRecordedAt: nil, payerAppId: testCase.role == .payer ? testCase.appId : nil, executionClaimAppId: nil, - terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, @@ -128,8 +128,8 @@ final class PaykitContactLifecycleTests: XCTestCase { counterparty: publicKey, paymentRequestId: UUID().uuidString, localRole: .payer, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: nil, proposalAppId: "paykit-server", payerAppId: "paykit-server", executionClaimAppId: nil, - terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + proposalEventId: nil, proposalAppId: "paykit-server", proposalRecordedAt: nil, payerAppId: "paykit-server", executionClaimAppId: nil, + terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil @@ -527,6 +527,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { queued: [], cleared: updates.filter { update in !failed.contains { $0.counterparty == update.counterparty } } .map { .init(counterparty: $0.counterparty, outboundMessageId: 1, error: nil) }, + acknowledged: [], failedToQueue: failed.map { .init(counterparty: $0.counterparty, outboundMessageId: nil, error: nil) }, failedToDeliver: [] ) @@ -540,7 +541,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { if failWithdrawal { throw PubkyServiceError.sessionNotActive } - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } override func blockPeer(counterparty: String) async throws -> LinkedPeerRecord { diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index 0754b5bfc..c99c8e880 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -100,6 +100,7 @@ final class PaykitIssuerInteropTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "71300000-0000-4000-8000-000000000002", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -116,6 +117,7 @@ final class PaykitIssuerInteropTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e78b9ca58..f76144cf9 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1550,6 +1550,61 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(priorities.delivery, [.background]) } + func testPreparationAcceptsAProofThatFillsOneMessageExactly() async throws { + for (kind, endpoint) in proofSizeCases { + let reference = try paymentReference(fillingProofOf: kind, endpoint: endpoint, extraBytes: 0) + let record = try paymentRequestRecord(endpoints: [endpoint], paymentReference: reference) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: kind) + + let prepared = await store.snapshot() + XCTAssertEqual(prepared.map(\.requestId), [request.id], "\(kind)") + } + } + + func testPreparationRefusesARequestWhoseProofCannotFitOneMessage() async throws { + for (kind, endpoint) in proofSizeCases { + let reference = try paymentReference(fillingProofOf: kind, endpoint: endpoint, extraBytes: 1) + let record = try paymentRequestRecord(endpoints: [endpoint], paymentReference: reference) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + do { + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: kind) + XCTFail("A request whose proof cannot be delivered must not be paid: \(kind)") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + + let prepared = await store.snapshot() + XCTAssertTrue(prepared.isEmpty, "\(kind)") + } + } + + func testPreparationMeasuresTheAllowanceIdTheProofWillCarry() async throws { + let endpoint = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + let reference = try paymentReference(fillingProofOf: .lightning, endpoint: endpoint, extraBytes: 0) + let record = try paymentRequestRecord(endpoints: [endpoint], paymentReference: reference) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + do { + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .lightning, + allowanceId: "30000000-0000-4000-8000-000000000000") + XCTFail("The Allowance ID makes this proof too long for one message") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + + let prepared = await store.snapshot() + XCTAssertTrue(prepared.isEmpty) + } + func testMismatchedLightningPreimageIsNotSubmitted() async throws { let record = try paymentRequestRecord() let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -3415,12 +3470,35 @@ final class PaykitPaymentProofServiceTests: XCTestCase { return service } + private var proofSizeCases: [(PaykitPaymentProofKind, String)] { + [ + (.lightning, PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue), + (.onchain, PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue), + ] + } + + /// A Payment Reference that makes the Payment Proof Event Message of `kind` exactly `1000 + extraBytes` bytes long. + private func paymentReference(fillingProofOf kind: PaykitPaymentProofKind, endpoint: String, extraBytes: Int) throws -> String { + let baseLength = try Paykit.paymentProofEventLen( + localAppId: "bitkit", + paymentReference: PaymentReference(text: "a"), + submission: PaymentProofSubmission( + billingPeriod: nil, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, allowanceId: nil, + conversionQuoteId: nil, proof: PrivateJsonObject(text: PaykitPaymentProofSize.placeholderProof(for: kind)) + ) + ) + let referenceBytes = PaykitSdkService.maximumMessageBytes - (Int(baseLength) - 1) + extraBytes + let wideCharacters = referenceBytes / 4 + return String(repeating: "💜", count: wideCharacters) + String(repeating: "a", count: referenceBytes - wideCharacters * 4) + } + private func paymentRequestRecord( endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], paymentProofs: [PaymentProofRecord] = [], paymentRequestId: String = "550e8400-e29b-41d4-a716-446655440000", state: PaymentRequestLifecycleState = .proposed, - recurrence: PaymentRequestRecurrence? = nil + recurrence: PaymentRequestRecurrence? = nil, + paymentReference: String = "invoice-123" ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, @@ -3432,11 +3510,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), - paymentReference: PaymentReference(text: "invoice-123"), + paymentReference: PaymentReference(text: paymentReference), proposalExpiresAt: nil, recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, @@ -3448,6 +3527,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 46e17b436..0a0c000e3 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3109,6 +3109,76 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertFalse(manager.isCreatingRequest) } + func testProposalTooLargeForOneMessageAsksToShortenWhatTheUserWrote() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] + ) + let tooLarge: PaykitError = .Protocol( + code: "message_too_large", + context: "Private Application Message (1024 bytes) exceeds pubky-noise message size (1000 bytes)" + ) + await sdk.setProposalError(tooLarge) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + do { + _ = try await manager.propose( + PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: String(repeating: "💜", count: 256), + expiresAt: now.addingTimeInterval(60)), + to: target + ) + XCTFail("A proposal that does not fit one message must not be created") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .noteTooLong) + } + do { + _ = try await manager.proposeSubscription( + PaykitSubscriptionDraft(amount: PaykitAmount(asset: .btc, atomic: 1000), name: "Support", description: "Monthly", + frequency: .month, expiresAt: now.addingTimeInterval(60), iconData: nil), + to: target + ) + XCTFail("A subscription that does not fit one message must not be created") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong) + } + + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + XCTAssertTrue(manager.historyRequests.isEmpty) + XCTAssertTrue(manager.subscriptions.isEmpty) + XCTAssertFalse(manager.isCreatingRequest) + } + + func testOtherProposalProtocolErrorsAreNotReportedAsTooLong() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] + ) + let refusal: PaykitError = .Protocol(code: "protocol_error", context: "Invalid terms") + await sdk.setProposalError(refusal) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + do { + _ = try await manager.propose( + PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "Coffee", expiresAt: now.addingTimeInterval(60)), + to: target + ) + XCTFail("The SDK refusal must reach the caller") + } catch { + XCTAssertEqual(error as? PaykitError, refusal) + } + } + func testCreatorPendingProposalCanBeDeletedButFixedEndProposalCannot() throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let openEnded = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( @@ -10271,6 +10341,78 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testUsdtTransferMadeBeforeTheRequestWasRecordedDoesNotPayIt() async throws { + defer { try? Keychain.delete(key: .paykitUsdtPayments) } + let identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.MethodId.usdtArbitrum.rawValue + let proof = UsdtPaymentProof(chainId: "42161", transactionHash: "transaction", receiptLogIndex: "0", signature: "signature") + let receivedProof = try PaymentProofRecord( + eventId: "750e8400-e29b-41d4-a716-446655440000", outboundMessageId: nil, outboundStatus: nil, streamItemId: 2, + paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: nil, paymentAppId: "bitkit", + paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil, + proof: PrivateJsonObject(text: String(decoding: JSONEncoder().encode(PaykitUsdtPaymentService.Proof(proof)), as: UTF8.self)), + recordedAt: "2027-01-15T08:01:00Z" + ) + let transferredAt = Date(timeIntervalSince1970: 1_800_000_000) + let tolerance = PaykitUsdtPaymentService.transferTimeTolerance + // The SDK writes RFC 3339 with an offset and up to nine fraction digits. + func sdkTimestamp(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date).replacingOccurrences(of: "Z", with: ".123456789+00:00") + } + let cases: [(proposalRecordedAt: String?, isBeforeRequest: Bool)] = [ + (timestamp(transferredAt.addingTimeInterval(tolerance + 60)), true), + (sdkTimestamp(transferredAt.addingTimeInterval(tolerance + 60)), true), + (timestamp(transferredAt.addingTimeInterval(tolerance - 60)), false), + (sdkTimestamp(transferredAt.addingTimeInterval(tolerance - 60)), false), + (timestamp(transferredAt.addingTimeInterval(-60)), false), + (nil, false), + ("not a time", false), + ] + for testCase in cases { + try Keychain.delete(key: .paykitUsdtPayments) + var received = try paymentRequestRecord(id: "550e8400-e29b-41d4-a716-446655440002", counterparty: contact, + state: .proofSubmitted, role: .payee, amount: "0.05", asset: "usdt", + endpoints: [endpoint], paymentProofs: [receivedProof]) + received.proposalRecordedAt = testCase.proposalRecordedAt + let service = PaykitUsdtPaymentService() + await service.reconcile( + context: { ("wallet", identity) }, + storedTransfer: { id in self.usdtTransfer(id, status: .confirmed) }, + paymentProof: { _, _ in nil }, + submitProof: { _ in false }, + paymentRequests: { [received] }, + verifyPayment: { _, _ in + UsdtVerifiedPayment(paymentId: "42161:transaction:0", transferId: "transaction:0", sender: contact, + recipient: "wallet", amount: 50000, timestamp: UInt64(transferredAt.timeIntervalSince1970)) + } + ) + + let receipt = try XCTUnwrap(service.receipts.first, "\(testCase)") + XCTAssertEqual(receipt.beforeRequest, testCase.isBeforeRequest, "\(testCase)") + XCTAssertEqual(receipt.satisfied, !testCase.isBeforeRequest, "\(testCase)") + XCTAssertEqual(try service.satisfiedProofs(identity: identity).isEmpty, testCase.isBeforeRequest, "\(testCase)") + let request = try XCTUnwrap(PaykitPaymentRequest(historyRecord: received, now: transferredAt)) + let status = PaymentRequestDisplay.status(for: request, isActionable: false, receipt: receipt) + XCTAssertEqual( + status.hasPrefix(t(testCase.isBeforeRequest ? "wallet__payment_request_before_request" : "wallet__payment_request_payment_verified")), + true, + "\(testCase): \(status)" + ) + if testCase.isBeforeRequest { + let alsoLate = PaykitUsdtPaymentService.Receipt( + wallet: receipt.wallet, identity: receipt.identity, requestId: receipt.requestId, paymentId: receipt.paymentId, + proofEventId: receipt.proofEventId, verified: true, transferId: receipt.transferId, amount: receipt.amount, + receivedAt: receipt.receivedAt, underpaid: true, afterExpiry: true, beforeRequest: true + ) + XCTAssertTrue(PaymentRequestDisplay.status(for: request, isActionable: false, receipt: alsoLate) + .hasPrefix(t("wallet__payment_request_before_request"))) + } + } + } + func testUsdtProofCancellationStopsLaterAttemptsAndReceiptVerification() async throws { defer { try? Keychain.delete(key: .paykitUsdtPayments) } let identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -10336,6 +10478,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { proposalOutboundStatus: proposalOutboundStatus, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: proposalAppId, + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -10352,6 +10495,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ), acceptedEventId: acceptedEventId, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: rejectedEventId, rejectedOutboundStatus: nil, canceledEventId: canceledEventId, @@ -10488,6 +10632,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var failingCapabilityKeys: Set = [] private var capabilityLookupPriorities: [PaykitPublicReadPriority] = [] private var proposalResult: PaymentRequestRecord? + private var proposalError: Error? private var uploadCount = 0 private var shouldPauseNextUpload = false private var isUploadPaused = false @@ -10763,6 +10908,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym guard PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } + if let proposalError { + throw proposalError + } guard var result = proposalResult else { throw PaymentRequestSdkMockError.requestMissing } @@ -11005,6 +11153,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym proposalResult = record } + func setProposalError(_ error: Error?) { + proposalError = error + } + func setProcessReports(_ reports: [OutboundPrivateCounterpartySendReport]) { processReports = reports } diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 821dbf3dc..39ec92f12 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -1202,12 +1202,38 @@ final class PaykitPaymentStateBackupTests: XCTestCase { XCTAssertEqual(attempt.billingPeriod?.sdkValue.startsAt, "2026-10-01T00:00:00.125Z") XCTAssertEqual(receipt.amount.atomic, 50000) XCTAssertEqual(receipt.requestId, attempt.requestId) + XCTAssertEqual(receipt.beforeRequest, false) + XCTAssertTrue(receipt.satisfied) let encoded = try JSONEncoder().encode(PaykitUsdtStateBackup(attempts: [.init(attempt)], receipts: [.init(receipt)])) let restored = try JSONDecoder().decode(PaykitUsdtStateBackup.self, from: encoded) XCTAssertEqual(try restored.attempts.first?.restored(), attempt) XCTAssertEqual(try restored.receipts.first?.restored(), receipt) } + @MainActor + func testUsdtReceiptPaidBeforeItsRequestStaysUnsatisfiedAcrossABackup() throws { + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "pubky" + String(repeating: "y", count: 52)) + let receipt = PaykitUsdtPaymentService.Receipt(wallet: "wallet", identity: identity, requestId: requestId, + paymentId: "payment", proofEventId: "proof", verified: true, + transferId: "transfer", amount: PaykitAmount(asset: .usdt, atomic: 50000), + receivedAt: Date(timeIntervalSince1970: 1000), underpaid: false, afterExpiry: false, + beforeRequest: true) + XCTAssertFalse(receipt.satisfied) + + let encoded = try JSONEncoder().encode(PaykitUsdtStateBackup(attempts: [], receipts: [.init(receipt)])) + let restored = try XCTUnwrap(JSONDecoder().decode(PaykitUsdtStateBackup.self, from: encoded).receipts.first).restored() + + XCTAssertEqual(restored, receipt) + XCTAssertFalse(restored.satisfied) + + var stored = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(receipt)) as? [String: Any]) + stored.removeValue(forKey: "beforeRequest") + let earlierReceipt = try JSONDecoder().decode(PaykitUsdtPaymentService.Receipt.self, + from: JSONSerialization.data(withJSONObject: stored)) + XCTAssertNil(earlierReceipt.beforeRequest) + XCTAssertTrue(earlierReceipt.satisfied, "A receipt saved before the flag existed keeps counting as paid") + } + @MainActor func testUsdtRestorePreservesStartedPaymentsWhenSnapshotsOverlap() throws { let service = PaykitUsdtPaymentService() diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 2d5ed36a5..ddf51348f 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -303,7 +303,7 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { try PaymentRequestRecord( counterparty: counterparty, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: .payee, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "paykit-server", payerAppId: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "paykit-server", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), @@ -311,7 +311,7 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { paymentEndpoints: [identifier: PublicPaykitService.serializePayload(value: address)], requiredAppId: nil, conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: "2027-01-15T08:00:00Z", invalidReason: nil ) diff --git a/BitkitTests/PaykitRequestPricingTests.swift b/BitkitTests/PaykitRequestPricingTests.swift index 8a63d2c36..bfabcb78e 100644 --- a/BitkitTests/PaykitRequestPricingTests.swift +++ b/BitkitTests/PaykitRequestPricingTests.swift @@ -113,6 +113,51 @@ final class PaykitRequestPricingTests: XCTestCase { } } + @MainActor + func testUsdtProofSizeCountsTheBillingPeriodAndQuote() throws { + let placeholder = try PaykitPaymentProofSize.placeholderProof(for: .usdt) + let baseLength = try Paykit.paymentProofEventLen( + localAppId: "bitkit", + paymentReference: PaymentReference(text: "a"), + submission: PaymentProofSubmission(billingPeriod: nil, paymentAppId: "bitkit", + paymentEndpointIdentifier: "usdt-arbitrum-address", allowanceId: nil, + conversionQuoteId: nil, proof: PrivateJsonObject(text: placeholder)) + ) + let fillingBytes = PaykitSdkService.maximumMessageBytes - (Int(baseLength) - 1) + let reference = String(repeating: "💜", count: fillingBytes / 4) + String(repeating: "a", count: fillingBytes % 4) + let withPeriod = UsdtPaymentProofBinding(payer: String(repeating: "y", count: 52), payee: String(repeating: "y", count: 52), + paymentAppId: "bitkit", paymentRequestId: UUID().uuidString.lowercased(), + paymentReference: reference, paymentEndpointIdentifier: "usdt-arbitrum-address", + periodStartsAt: "2027-01-01T00:00:00Z", periodEndsAt: "2027-02-01T00:00:00Z", + conversionQuoteId: "") + var withQuote = withPeriod + withQuote.periodStartsAt = "" + withQuote.periodEndsAt = "" + withQuote.conversionQuoteId = UUID().uuidString.lowercased() + + for binding in [withPeriod, withQuote] { + XCTAssertThrowsError(try PaykitUsdtPaymentService.validateProofSize(binding: binding)) + } + } + + func testBitkitRequestsLeaveRoomForTheirLongestProof() throws { + let period = BillingPeriod(startsAt: "2027-01-01T00:00:00.123456789Z", endsAt: "2027-02-01T00:00:00.123456789Z") + let endpoints: [(PaykitPaymentProofKind, PublicPaykitService.MethodId)] = [ + (.lightning, .bitcoinLightningBolt11), (.onchain, .bitcoinOnchainP2wpkh), (.usdt, .usdtArbitrum), + ] + for (kind, endpoint) in endpoints { + XCTAssertNoThrow(try PaykitPaymentProofSize.validate( + paymentReference: "bitkit-\(UUID().uuidString)", + paymentAppId: "bitkit", + paymentEndpointIdentifier: endpoint.rawValue, + billingPeriod: period, + allowanceId: UUID().uuidString.lowercased(), + conversionQuoteId: UUID().uuidString.lowercased(), + kind: kind + ), "\(kind)") + } + } + func testRatesAreRequiredOnlyWhenIssuingBitcoinConversions() throws { let usd = PublicPaykitService.MethodId.usdtArbitrum.rawValue XCTAssertEqual(try PaykitRequestPricing.rates(requested: .usd, endpoints: [usd], market: nil, at: now), @@ -136,6 +181,33 @@ final class PaykitRequestPricingTests: XCTestCase { XCTAssertThrowsError(try PaykitUsdtPaymentService.validateProofSize(binding: oversized)) } + @MainActor + func testUsdtProofSizeIsMeasuredExactlyWithTheLongestValidProof() throws { + let placeholder = try PaykitPaymentProofSize.placeholderProof(for: .usdt) + let proof = try JSONDecoder().decode(PaykitUsdtPaymentService.Proof.self, from: Data(placeholder.utf8)) + XCTAssertEqual(proof.type, PaykitPaymentProofKind.usdt.rawValue) + XCTAssertEqual(proof.receiptLogIndex.count, 78) + + var binding = UsdtPaymentProofBinding(payer: String(repeating: "y", count: 52), payee: String(repeating: "y", count: 52), + paymentAppId: "bitkit", paymentRequestId: UUID().uuidString.lowercased(), + paymentReference: "a", paymentEndpointIdentifier: "usdt-arbitrum-address", + periodStartsAt: "", periodEndsAt: "", conversionQuoteId: "") + let baseLength = try Paykit.paymentProofEventLen( + localAppId: "bitkit", + paymentReference: PaymentReference(text: "a"), + submission: PaymentProofSubmission(billingPeriod: nil, paymentAppId: "bitkit", + paymentEndpointIdentifier: "usdt-arbitrum-address", allowanceId: nil, + conversionQuoteId: nil, proof: PrivateJsonObject(text: placeholder)) + ) + let fillingBytes = PaykitSdkService.maximumMessageBytes - (Int(baseLength) - 1) + binding.paymentReference = String(repeating: "💜", count: fillingBytes / 4) + String(repeating: "a", count: fillingBytes % 4) + XCTAssertNoThrow(try PaykitUsdtPaymentService.validateProofSize(binding: binding)) + binding.paymentReference += "a" + XCTAssertThrowsError(try PaykitUsdtPaymentService.validateProofSize(binding: binding)) { + XCTAssertEqual($0 as? PaykitPaymentRequestError, .requestUnavailable) + } + } + func testRecurringPaymentsKeepTheirChosenQuoteAndInclusiveDeadline() throws { let period = PaykitBillingPeriod(startsAt: now, endsAt: now.addingTimeInterval(86400)) let first = PaymentConversionQuoteRecord(eventId: "first", billingPeriod: period.sdkValue, diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index ec32557e4..95ca4897e 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -907,7 +907,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { localRole: .payee, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "71300000-0000-4000-8000-000000000002", - proposalAppId: appId, payerAppId: nil, executionClaimAppId: nil, + proposalAppId: appId, proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), @@ -915,7 +915,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { paymentEndpoints: nil, requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 42, diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index 56ba56010..6e64ab5a5 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -1253,7 +1253,7 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { updates _: [PrivatePaymentListReservationUpdateInput], clearUnlistedLinkedPeers _: Bool ) async throws -> PrivatePaymentListDeliveryReport { await intake() - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } override func submitPaymentProof( diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 0dfe59635..eca92843c 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -284,6 +284,7 @@ final class PaymentNavigationHelperTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -300,6 +301,7 @@ final class PaymentNavigationHelperTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 5bf4526f3..16881d432 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -282,7 +282,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("Failed links must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: { [] } ) ) @@ -1048,7 +1048,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } syncedUpdates = updates published.fulfill() - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1092,7 +1092,7 @@ final class PrivatePaykitServiceTests: XCTestCase { currentPublicKey: { "pubkylocal" }, ensureLink: { linked.append($0); return .linked }, buildEndpoints: { _ in [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) var sessionCurrent = true var prune: Task? @@ -1153,7 +1153,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Ended sessions must not build endpoints"); return [] }, syncPaymentLists: { _ in XCTFail("Ended sessions must not publish") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1195,7 +1195,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { updates in published += updates.map(\.counterparty) - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1617,7 +1617,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [] }, syncPaymentLists: { updates in published += updates.map(\.counterparty) - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) _ = await service.rememberSavedContacts([publicKey], replacing: true) @@ -1663,7 +1663,7 @@ final class PrivatePaykitServiceTests: XCTestCase { publishing.fulfill() for await _ in resume {} } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) let wallet = WalletViewModel() @@ -2266,6 +2266,7 @@ final class PrivatePaykitServiceTests: XCTestCase { withdrawals += 1 return .init( queued: [], cleared: [.init(counterparty: key, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -2317,12 +2318,14 @@ final class PrivatePaykitServiceTests: XCTestCase { if shouldFail, failureStage == "queue" { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], + acknowledged: [], failedToQueue: [PrivatePaymentListSyncChange(counterparty: publicKey, outboundMessageId: nil, error: nil)], failedToDeliver: [] ) } return PrivatePaymentListDeliveryReport( queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -2391,6 +2394,7 @@ final class PrivatePaykitServiceTests: XCTestCase { cleared.append(contentsOf: $0) return PrivatePaymentListDeliveryReport( queued: [], cleared: $0.map { .init(counterparty: $0, outboundMessageId: 1, error: nil) }, + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -2453,6 +2457,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(Set(keys), [failed, successful]) return .init( queued: [], cleared: [.init(counterparty: successful, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: failQueue ? [.init(counterparty: failed, outboundMessageId: nil, error: nil)] : [], failedToDeliver: failQueue ? [] : [.init( counterparty: failed, outboundMessageId: 2, reservationId: nil, @@ -2506,7 +2511,7 @@ final class PrivatePaykitServiceTests: XCTestCase { }, syncPaymentLists: { _ in XCTFail("An unsupported contact should not receive a private list") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) @@ -2548,6 +2553,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) @@ -2620,6 +2626,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) @@ -2674,7 +2681,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } XCTAssertEqual(updates.map(\.counterparty), [key]) publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: { try await deferOnce("peers"); return [] }, canPublish: { try await deferOnce("availability"); return true } @@ -2726,7 +2733,7 @@ final class PrivatePaykitServiceTests: XCTestCase { syncPaymentLists: { updates in XCTAssertEqual(updates.map(\.counterparty), [key]) publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { canPublish } )) @@ -2868,7 +2875,7 @@ final class PrivatePaykitServiceTests: XCTestCase { deferred.fulfill() throw PaykitPaymentRequestError.operationInProgress } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { if isPaused { @@ -2944,7 +2951,7 @@ final class PrivatePaykitServiceTests: XCTestCase { deferred.fulfill() throw PaykitPaymentRequestError.operationInProgress } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { isActive && sharingEnabled }, isApplicationActive: { @@ -3463,7 +3470,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertFalse(Task.isCancelled) XCTAssertEqual(updates.map(\.counterparty), [key]) published = true - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) _ = await service.syncLocalEndpointPublication( @@ -3487,7 +3494,10 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("No prepared link"); return [] }, - syncPaymentLists: { _ in XCTFail("No prepared link"); return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in + XCTFail("No prepared link") + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) + }, linkedPeers: { reads += 1; return [] } ) let task = Task { @@ -3647,7 +3657,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { _ in publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) var contactState = PrivatePaykitService.ContactState() @@ -3669,6 +3679,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } return .init( queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -3750,7 +3761,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Cleanup invalidates preparation"); return [] }, syncPaymentLists: { _ in XCTFail("Cleanup invalidates publication") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) ) @@ -3791,7 +3802,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Removed contacts must not reserve addresses"); return [] }, syncPaymentLists: { _ in XCTFail("Removed contacts must not receive publications") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) ) @@ -3810,7 +3821,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) for _ in 0 ..< 3 { _ = await service.syncLocalEndpointPublication( @@ -3851,7 +3862,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("Failed links must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: peers ) _ = await service.syncLocalEndpointPublication( @@ -3888,7 +3899,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) let removals: [(PrivatePaykitService) async -> Void] = [ { await $0.clearContactState(publicKey: publicKey) }, diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index 1249f1145..d5e98448a 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -236,6 +236,28 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { } } + func testApprovalFailureExplainsARejectedSignupCodeAndAFailedConnection() { + XCTAssertEqual( + pubkyAuthApprovalFailureDescription(PaykitError.Identity(code: "signup_code_rejected", context: "Token already used")), + t("profile__auth_error_signup_code_rejected") + ) + XCTAssertEqual( + pubkyAuthApprovalFailureDescription(PaykitError.Transport(code: "transport_error", context: "homeserver answered 503")), + t("pubky_auth__identity_unavailable_desc") + ) + } + + func testOtherApprovalFailuresKeepTheirOwnDescription() { + let errors: [Error] = [ + PaykitError.Identity(code: "identity_error", context: "Clock skew"), + PaykitError.Policy(code: "policy_error", context: "Identity uses another homeserver"), + PubkyAuthRequestError.invalidPaymentDetails, + ] + for error in errors { + XCTAssertEqual(pubkyAuthApprovalFailureDescription(error), error.localizedDescription) + } + } + func testAuthDisplayPublicKeyOmitsPubkyPrefix() { XCTAssertEqual(pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg"), "3rsd...w5xg") XCTAssertEqual(pubkyAuthDisplayPublicKey("3rsd123456789w5xg"), "3rsd...w5xg") diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift index 99c9a04db..e9275733e 100644 --- a/BitkitTests/ReceiveSheetSessionTests.swift +++ b/BitkitTests/ReceiveSheetSessionTests.swift @@ -67,11 +67,13 @@ final class ReceiveSheetSessionTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: nil, proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: nil, acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index d108f5773..c79af1aba 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -135,7 +135,7 @@ final class SendSheetLifecycleTests: XCTestCase { counterparty: "pubkypayee", paymentRequestId: UUID().uuidString, localRole: .payer, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: UUID().uuidString, proposalAppId: "bitkit", + proposalEventId: UUID().uuidString, proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), @@ -145,7 +145,7 @@ final class SendSheetLifecycleTests: XCTestCase { paymentEndpoints: nil, requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 16492304e..48c00c10a 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -57,6 +57,7 @@ final class TransferServiceActivityTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -73,6 +74,7 @@ final class TransferServiceActivityTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/changelog.d/next/1237.fixed.md b/changelog.d/next/1237.fixed.md new file mode 100644 index 000000000..598544a10 --- /dev/null +++ b/changelog.d/next/1237.fixed.md @@ -0,0 +1 @@ +Payment requests that cannot fit one Paykit message are now refused before anything is sent or paid, a note that is too long asks to be shortened, Pubky authorization explains a refused signup code or a failed connection, and USDT received before a request was made no longer counts as paying it. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 167acc340..1283e8107 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -152,6 +152,10 @@ check uses a supported 375-point-wide device with enlarged Dynamic Type, while A `contact-request-or-pay.xml` also needs a third linked contact that publishes no payment endpoints, so the failure and retry steps are reproducible. +`request-note-too-long.xml` checks that a request whose note does not fit one Paykit message is +refused with "Shorten the note and try again." before anything is sent, and sends after the note is +shortened. It uses the same two-instance setup. + `success-dismissal.xml` matches Android's journey: after paying a Lightning request, closing success must leave no error toast or duplicate payment; closing a later unpaid request must not send it. Use two linked disposable regtest wallets and observe or record dismissal to catch short-lived toasts. diff --git a/journeys/payment-requests/request-note-too-long.xml b/journeys/payment-requests/request-note-too-long.xml new file mode 100644 index 000000000..ff428a22d --- /dev/null +++ b/journeys/payment-requests/request-note-too-long.xml @@ -0,0 +1,18 @@ + + + Verifies that a Payment Request whose note does not fit one Paykit message is refused before anything is sent, with a message asking to shorten the note, and that the same request sends once the note is shortened. Requires two Bitkit instances saved as each other's contacts and linked as identities; see README.md. Paste the long note from the clipboard (xcrun simctl pbcopy) rather than typing it. + + + Copy a note of 256 "💜" characters to the requester's clipboard + On the requester, open the payer contact, tap Pay (id "ContactPay") and tap Request on the sheet + Enter 1,000 sats on the Payment Request amount screen and tap Continue (id "PaymentRequestAmountContinue") + Paste the copied note into the note field (id "PaymentRequestNote") + Tap Send (id "PaymentRequestSend") + Verify an error toast says "Shorten the note and try again." and the request screen stays open with Send (id "PaymentRequestSend") visible + On the payer, verify no new Payment Request appears + On the requester, replace the note with "Coffee" and tap Send (id "PaymentRequestSend") + Verify completion (id "PaymentRequestSent") is shown + On the payer, verify the Payment Request confirmation screen (id "PaymentRequestConfirm") appears with 1,000 sats and For (id "PaymentRequestFor") shows "Coffee" + Close the Payment Request confirmation screen without paying + + diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index b715cd24f..66e388e29 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -17,10 +17,13 @@ The journey uses a syntactically valid dummy request and does not contact its re - `grant-signup.xml` checks consent, cancellation, homeserver registration, and profile setup for a fresh identity. - `grant-signup-existing-identity.xml` checks approval with an existing identity without opening profile setup. +- `grant-signup-rejected-code.xml` checks the message for a signup code the homeserver refuses, and that no identity is created. -Both require a fresh `pubkyauth://signup_grant` URL from a controlled requesting app, a reachable +The first two require a fresh `pubkyauth://signup_grant` URL from a controlled requesting app, a reachable homeserver and authorization relay, and a valid invite when required. The new-identity fixture -uses ordinary app permissions without a Bitkit companion claim. +uses ordinary app permissions without a Bitkit companion claim. The rejected-code journey instead +needs a URL whose signup token that homeserver refuses, such as the token of a link another wallet +already completed. Deliver each grant signup request through the main wallet scanner: copy the complete URL to the device clipboard, tap **Scan**, then **Paste**, and allow clipboard access if prompted. Camera diff --git a/journeys/pubky-auth/grant-signup-rejected-code.xml b/journeys/pubky-auth/grant-signup-rejected-code.xml new file mode 100644 index 000000000..4a9af7699 --- /dev/null +++ b/journeys/pubky-auth/grant-signup-rejected-code.xml @@ -0,0 +1,17 @@ + + + Verifies that a signup link whose signup code the homeserver refuses shows a specific message, keeps the authorization sheet open for another try, and creates no identity. Precondition: an isolated unlocked wallet with Paykit enabled and no Pubky identity. A controlled requesting app supplies a signup_grant URL for a reachable homeserver and relay whose signup token that homeserver has already used or does not know, for example the token of a signup_grant URL another wallet already completed. The URL requests ordinary app permissions without a Bitkit companion claim. + + + Copy the prepared signup_grant URL with the refused signup token to the device clipboard + Tap the main wallet Scan button (id "Scan") + If camera permission is requested, deny it to use clipboard input + Tap Paste + Allow clipboard access if prompted + Tap Authorize (id "PubkyAuthAuthorize") and complete PIN or biometric approval when enabled + Verify an error toast says "The homeserver did not accept this signup code. Try again or use another signup link." + Verify the authorization sheet is still open with Authorize (id "PubkyAuthAuthorize") enabled + Verify the Create Profile screen (id "CreateProfileSave") is not shown + Close the authorization sheet and verify the wallet still has no Pubky identity + + From 6f8f49ffd83e7a6afe64601e2a59916335380d78 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 17:55:06 +0100 Subject: [PATCH 02/10] Merge pull request #1235 from synonymdev/feat/pubky-app-profile feat: use the pubky.app profile directly (cherry picked from commit a1dbb8324d89a50c91f1d5433dd6951801887c34) --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Components/ProfileEditFormView.swift | 23 +- Bitkit/Managers/PubkyAppProfileStore.swift | 620 ++++++++++++++++++ Bitkit/Managers/PubkyProfileManager.swift | 132 +++- Bitkit/Models/PubkyProfile.swift | 46 +- .../Localization/ar.lproj/Localizable.strings | 4 +- .../Localization/ca.lproj/Localizable.strings | 4 +- .../Localization/cs.lproj/Localizable.strings | 4 +- .../Localization/de.lproj/Localizable.strings | 4 +- .../Localization/el.lproj/Localizable.strings | 4 +- .../Localization/en.lproj/Localizable.strings | 4 +- .../es-419.lproj/Localizable.strings | 4 +- .../Localization/es.lproj/Localizable.strings | 4 +- .../Localization/fr.lproj/Localizable.strings | 4 +- .../Localization/it.lproj/Localizable.strings | 4 +- .../Localization/nl.lproj/Localizable.strings | 4 +- .../Localization/pl.lproj/Localizable.strings | 4 +- .../pt-BR.lproj/Localizable.strings | 4 +- .../Localization/pt.lproj/Localizable.strings | 4 +- .../Localization/ru.lproj/Localizable.strings | 4 +- Bitkit/Services/PubkyService.swift | 102 ++- Bitkit/Views/Contacts/EditContactView.swift | 2 +- Bitkit/Views/Profile/CreateProfileView.swift | 18 +- Bitkit/Views/Profile/EditProfileView.swift | 32 +- Bitkit/Views/Profile/ProfileView.swift | 12 +- BitkitTests/PaykitSdkIdentityCheckTests.swift | 33 + BitkitTests/PubkyAppProfileStoreTests.swift | 209 ++++++ changelog.d/next/1235.added.md | 1 + journeys/profile/README.md | 2 + journeys/profile/edit-pubky-app-profile.xml | 18 + 31 files changed, 1260 insertions(+), 56 deletions(-) create mode 100644 Bitkit/Managers/PubkyAppProfileStore.swift create mode 100644 BitkitTests/PubkyAppProfileStoreTests.swift create mode 100644 changelog.d/next/1235.added.md create mode 100644 journeys/profile/edit-pubky-app-profile.xml diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 7b58a6389..a20922b38 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1200,7 +1200,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.1-rc6"; + version = "0.1.1-rc7"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 010c47429..0542f0515 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "9919198a8a810652945f7366defacae8c3135632", - "version" : "0.1.1-rc6" + "revision" : "8f1cd4e9fea8b99043f2437757dd27b0f96efb9e", + "version" : "0.1.1-rc7" } }, { diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index e092283d1..77e1480eb 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -17,6 +17,9 @@ struct ProfileEditFormView: View { var bioLabel: String = t("profile__create_bio_label") let bioPlaceholder: String let isSaving: Bool + var maxLinks: Int? + /// Whether the name can be saved; contact edits accept any non-empty name. + var isNameValid: (String) -> Bool = { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } let footerNote: String? let deleteLabel: String? var deleteSectionCaption: String = t("profile__edit_delete_section") @@ -154,14 +157,16 @@ struct ProfileEditFormView: View { linkRow(index: index) } - IconActionButton( - icon: "link", - isSystemIcon: true, - title: t("profile__create_add_link"), - accessibilityId: "ProfileEditAddLink" - ) { - dismissKeyboard() - showAddLinkSheet = true + if maxLinks.map({ links.count < $0 }) ?? true { + IconActionButton( + icon: "link", + isSystemIcon: true, + title: t("profile__create_add_link"), + accessibilityId: "ProfileEditAddLink" + ) { + dismissKeyboard() + showAddLinkSheet = true + } } } } @@ -294,7 +299,7 @@ struct ProfileEditFormView: View { await onSave() } .accessibilityIdentifier("ProfileEditSave") - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!isNameValid(name)) } .padding(.horizontal, 16) .padding(.top, 16) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift new file mode 100644 index 000000000..19c78e1ca --- /dev/null +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -0,0 +1,620 @@ +import Foundation +import Paykit + +enum PubkyAppProfileError: LocalizedError { + /// The pubky.app profile changed since Bitkit loaded it, so the save was not applied. + case changedElsewhere + /// The other fields were saved at `revision`, but the tag change was not. + case tagsNotSaved(revision: String) + + var errorDescription: String? { + switch self { + case .changedElsewhere: t("profile__edit_changed_error") + case .tagsNotSaved: t("profile__edit_tags_error") + } + } +} + +/// Orders profile writes with profile deletion per identity. A deletion refuses every write until it ends and every +/// later write of a load or save that started before it, and waits for the writes already under way. A load refreshes +/// the Paykit copy only when no save started since it read the copy, so it never writes back tags a save replaced. +struct ProfileWriteGate { + private var generations: [String: Int] = [:] + private var copyEpochs: [String: Int] = [:] + private var savesRunning: [String: Int] = [:] + private var deleting: Set = [] + private var writesInFlight: [String: Int] = [:] + private var writesSettled: [String: [CheckedContinuation]] = [:] + + /// The generation a load or save starts at, which its writes pass to `admit`. + func generation(_ key: String) -> Int { + generations[key, default: 0] + } + + /// Admits a write; pair each admitted write with `end`. + mutating func admit(_ key: String, generation: Int) throws { + guard !deleting.contains(key), generations[key, default: 0] == generation else { + throw PubkyServiceError.identityChanged + } + writesInFlight[key, default: 0] += 1 + } + + mutating func end(_ key: String) { + let remaining = writesInFlight[key, default: 1] - 1 + guard remaining == 0 else { + writesInFlight[key] = remaining + return + } + writesInFlight[key] = nil + writesSettled.removeValue(forKey: key)?.forEach { $0.resume() } + } + + /// Starts a deletion, which then waits until `hasWritesInFlight` is false (`onWritesSettled`) and ends with + /// `endDeletion`. + mutating func beginDeletion(_ key: String) { + generations[key, default: 0] += 1 + deleting.insert(key) + } + + /// Ends a deletion, moving the generation on again so a load or save that started during it writes nothing either. + mutating func endDeletion(_ key: String) { + generations[key, default: 0] += 1 + deleting.remove(key) + } + + /// The copy epoch a load reads the Paykit copy at; it moves on when a save starts and when it ends. + func copyEpoch(_ key: String) -> Int { + copyEpochs[key, default: 0] + } + + mutating func beginSave(_ key: String) { + copyEpochs[key, default: 0] += 1 + savesRunning[key, default: 0] += 1 + } + + mutating func endSave(_ key: String) { + copyEpochs[key, default: 0] += 1 + let remaining = savesRunning[key, default: 1] - 1 + savesRunning[key] = remaining > 0 ? remaining : nil + } + + /// Whether a load that read the Paykit copy at `epoch` may write it back: no save ran since or was running then. + func mayRefreshCopy(_ key: String, epoch: Int) -> Bool { + savesRunning[key, default: 0] == 0 && copyEpochs[key, default: 0] == epoch + } + + func hasWritesInFlight(_ key: String) -> Bool { + writesInFlight[key, default: 0] > 0 + } + + mutating func onWritesSettled(_ key: String, _ continuation: CheckedContinuation) { + writesSettled[key, default: []].append(continuation) + } +} + +/// The identity's own pubky.app profile, which Bitkit reads and edits directly. A save writes only the fields that differ +/// from the loaded profile, at the revision it was loaded at, so an edit made in pubky.app is never overwritten. Profile +/// tags have no pubky.app field: they live in Bitkit's public Paykit profile copy, which follows each save for other +/// Paykit apps. +actor PubkyAppProfileStore { + static let shared = PubkyAppProfileStore() + + // pubky.app profile limits (pubky-app-specs), applied when moving a Bitkit profile there. + private static let nameMaxLength = 50 + private static let bioMaxLength = 160 + private static let linksMaxCount = 5 + private static let linkTitleMaxLength = 100 + private static let linkUrlMaxLength = 300 + private static let avatarMaxBytes: UInt64 = 5 * 1024 * 1024 + + private static let pubkyAppProfileOwnersKey = "pubky_app_profile_owners" + + private static var pubkyAppProfileOwners: Set { + get { Set(UserDefaults.standard.stringArray(forKey: pubkyAppProfileOwnersKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: pubkyAppProfileOwnersKey) } + } + + private static let pubkyAppAvatarPendingKey = "pubky_app_avatar_pending" + + /// Identities whose Bitkit avatar is still to be moved to pubky.app, retried on each load. + private static var pubkyAppAvatarPending: Set { + get { Set(UserDefaults.standard.stringArray(forKey: pubkyAppAvatarPendingKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: pubkyAppAvatarPendingKey) } + } + + /// The profile each recently seen revision held, so a save diffs against what its form was built from. + private var profilesByRevision: [String: [String: PubkyProfile]] = [:] + private var revisionOrder: [String: [String]] = [:] + private var savedProfiles: [UUID: PubkyProfile] = [:] + private var pendingAvatarUris: [String: String] = [:] + private var tagsUnavailable: Set = [] + /// Moved on each time a load records whether it read the copy, so a save clears `tagsUnavailable` only if no load + /// set it since the save began. + private var tagsReadEpochs: [String: Int] = [:] + private var writeGate = ProfileWriteGate() + private var copyRefreshes: [String: [UUID: Task]] = [:] + private static let rememberedRevisions = 8 + + /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first; until + /// that move happens (signed out, or it cannot finish) the Bitkit profile is shown and the next save creates the + /// pubky.app profile. + func load(publicKey: String) async throws -> PubkyProfile { + let key = Self.normalized(publicKey) + let generation = writeGate.generation(key) + let copyEpoch = writeGate.copyEpoch(key) + let paykitData: PubkyProfileData? + var paykitReadError: Error? + do { + paykitData = try await PubkyService.fetchPaykitProfile(publicKey: key).map { + PubkyProfileData.from(paykitProfile: $0.profile) + } + tagsUnavailable.remove(key) + tagsReadEpochs[key, default: 0] += 1 + } catch { + Logger.warn("Failed to read Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") + paykitData = nil + paykitReadError = error + tagsUnavailable.insert(key) + tagsReadEpochs[key, default: 0] += 1 + } + let fetched = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) + var snapshot = fetched + let isSignedIn = await Self.isSignedIn(key) + // Once a pubky.app profile was seen, one missing there was deleted there, so it is not moved back from Bitkit. + let hadPubkyAppProfile = Self.pubkyAppProfileOwners.contains(key) + if fetched == nil, isSignedIn, !hadPubkyAppProfile, let paykitData { + snapshot = await migrate(paykitData, publicKey: key, generation: generation) + } else if let current = fetched, isSignedIn, Self.pubkyAppAvatarPending.contains(key), let uri = paykitData?.image { + if current.profile.image == nil, Self.isPaykitStorage(uri) { + snapshot = await moveAvatar(uri, onto: current, publicKey: key, generation: generation) ?? current + } else { + // An avatar set on pubky.app meanwhile wins over the one still to move, and only a Bitkit avatar on Paykit + // storage is moved. + Self.pubkyAppAvatarPending.remove(key) + } + } + guard let snapshot else { + if !hadPubkyAppProfile, let paykitData { + return paykitData.toProfile(publicKey: key) + } + if let paykitReadError { + throw paykitReadError + } + throw PubkyServiceError.profileNotFound + } + if !hadPubkyAppProfile { + Self.pubkyAppProfileOwners.insert(key) + } + let avatarPending = Self.pubkyAppAvatarPending.contains(key) + pendingAvatarUris[key] = avatarPending ? paykitData?.image : nil + var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) + profile.tagsUnread = paykitReadError != nil + if avatarPending, let uri = paykitData?.image { + profile = profile.withImageUrl(uri) + } + remember(profile, for: key) + if isSignedIn, !avatarPending, let paykitData, paykitData != PubkyProfileData.from(profile: profile), + writeGate.mayRefreshCopy(key, epoch: copyEpoch) + { + let id = UUID() + let refresh = Task { _ = try? await publishCopy(profile, expectedIdentity: key, generation: generation) } + copyRefreshes[key, default: [:]][id] = refresh + await refresh.value + copyRefreshes[key]?[id] = nil + } + return profile + } + + /// Saves `data` for `expectedIdentity`, or the signed-in identity. A save carrying the revision its form was built + /// from writes only the fields that differ from that revision; one without a revision creates the profile, and is + /// refused when a pubky.app profile exists by then. Throws `changedElsewhere` after reloading when pubky.app changed + /// the profile since that revision. + func save(_ data: PubkyProfileData, expectedIdentity: String?) async throws { + let data = Self.normalized(data) + let key = try await Self.identity(expectedIdentity) + let generation = writeGate.generation(key) + writeGate.beginSave(key) + defer { writeGate.endSave(key) } + // Unread when the edit began or now: either way the form's tags are not the stored ones. + let copyUnread = data.baseTagsUnread || tagsUnavailable.contains(key) + let readEpoch = tagsReadEpochs[key, default: 0] + var base: PubkyProfile? + if let baseRevision = data.baseRevision { + guard let known = profilesByRevision[key]?[baseRevision] else { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + base = known + } else if try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) != nil { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + var update = Self.update(from: base == nil ? Self.fitted(data) : data, base: base) + // A Bitkit avatar still on Paykit storage is moved by a later load rather than written to pubky.app as is. + let paykitAvatar = data.image.flatMap { Self.isPaykitStorage($0) ? $0 : nil } + if let image = update.image, Self.isPaykitStorage(image) { + update.image = nil + } + let snapshot: Paykit.PubkyProfileSnapshot + do { + snapshot = try await admitted(key, generation: generation) { + try await withPubkyAppScope(key) { + try await PubkyService.publishPubkyProfile(update, expectedRevision: data.baseRevision, expectedIdentity: key) + } + } + } catch PaykitError.ConcurrentUpdate { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + Self.pubkyAppProfileOwners.insert(key) + let previousTags = base?.tags ?? [] + let stored = await storedCopy(for: key, copyUnread: copyUnread) + if let paykitAvatar, snapshot.profile.image == nil { + Self.pubkyAppAvatarPending.insert(key) + pendingAvatarUris[key] = paykitAvatar + } else if snapshot.profile.image != nil || !(copyUnread && Self.pubkyAppAvatarPending.contains(key)) { + Self.pubkyAppAvatarPending.remove(key) + pendingAvatarUris[key] = nil + } else if let uri = stored?.image, Self.isPaykitStorage(uri) { + // The form could not show the avatar still to move, as the copy was unread; it stays pending. + pendingAvatarUris[key] = uri + } + let tags = stored.map { Self.tags(data.tags, keeping: $0.tags) } + var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: tags ?? previousTags) + if Self.pubkyAppAvatarPending.contains(key), let uri = pendingAvatarUris[key] { + profile = profile.withImageUrl(uri) + } + // Tags live only in the Paykit copy, so a tag change it does not take is reported, and the remembered base keeps + // the tags it last stored so a retry is reported again. + var copyError: Error? + if let tags { + // A copy refresh begun before this save started would otherwise land after it with the old tags. + for refresh in copyRefreshes[key, default: [:]].values { + await refresh.value + } + if tags == previousTags { + // No tag change to report, so the save does not wait for the copy: it follows as a tracked refresh, + // which the next save waits for and a deletion refuses or waits for. Its outcome leaves the unread flag + // alone, which a load may have set since. + let id = UUID() + copyRefreshes[key, default: [:]][id] = Task { + do { + try await publishCopy(profile, expectedIdentity: key, generation: generation) + } catch { + Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") + } + copyRefreshes[key]?[id] = nil + } + // This save read the copy itself and keeps its tags in the profile it stores, so they are readable + // again, unless a load found otherwise meanwhile. A save that writes the copy at once clears the flag + // only once that write succeeds, since a failed one stores the profile without the copy's tags. + if copyUnread, stored?.tags != nil, tagsReadEpochs[key, default: 0] == readEpoch { + tagsUnavailable.remove(key) + } + } else { + do { + try await publishCopy(profile, expectedIdentity: key, generation: generation) + tagsUnavailable.remove(key) + } catch { + copyError = error + } + } + } + var storedProfile = tags != nil && copyError == nil ? profile : profile.withTags(previousTags) + storedProfile.tagsUnread = copyUnread && (tags == nil || copyError != nil) + remember(storedProfile, for: key) + if let saveId = data.saveId { + savedProfiles[saveId] = storedProfile + } + if tags == nil || copyError != nil, data.tags != previousTags { + if let copyError { + Logger.warn("Saved the profile without its tag change: \(copyError)", context: "PubkyAppProfileStore") + } + throw PubkyAppProfileError.tagsNotSaved(revision: snapshot.revision) + } + } + + /// The profile a save stored, by the id its data carried; read once. + func savedProfile(saveId: UUID) -> PubkyProfile? { + savedProfiles.removeValue(forKey: saveId) + } + + private func remember(_ profile: PubkyProfile, for key: String) { + guard let revision = profile.revision else { return } + profilesByRevision[key, default: [:]][revision] = profile + var order = revisionOrder[key, default: []].filter { $0 != revision } + [revision] + while order.count > Self.rememberedRevisions { + profilesByRevision[key]?[order.removeFirst()] = nil + } + revisionOrder[key] = order + } + + /// What a save keeps from the Paykit copy when its form was built without it, as the form then lacked its tags and + /// the avatar still to move: empty when the copy is read, nil while it still cannot be read. + private func storedCopy(for key: String, copyUnread: Bool) async -> (tags: [String]?, image: String?)? { + guard copyUnread else { return (nil, nil) } + do { + let data = try await PubkyService.fetchPaykitProfile(publicKey: key).map { PubkyProfileData.from(paykitProfile: $0.profile) } + return (data?.tags ?? [], data?.image) + } catch { + return nil + } + } + + /// The form's tags, plus the stored ones it could not show after a failed copy read. + private static func tags(_ tags: [String], keeping storedTags: [String]?) -> [String] { + guard let storedTags else { return tags } + return storedTags + tags.filter { !storedTags.contains($0) } + } + + /// Uploads a JPEG avatar as a pubky.app blob and file and returns the file URI for the profile image. + func uploadAvatar(_ jpegData: Data, expectedIdentity: String?) async throws -> String { + let key = try await Self.identity(expectedIdentity) + return try await withPubkyAppScope(key) { + try await PubkyService.uploadPubkyAppAvatar(bytes: jpegData, contentType: "image/jpeg", expectedIdentity: key) + } + } + + /// A Bitkit profile fitted to pubky.app's limits, for a profile written there without a base: text on one line and + /// cut to length, and only links pubky.app can hold, at most five. + static func fitted(_ data: PubkyProfileData) -> PubkyProfileData { + var data = data + data.name = prefix(singleLine(data.name), nameMaxLength) + data.bio = prefix(singleLine(data.bio), bioMaxLength) + data.links = Array( + data.links + .compactMap { link -> PubkyProfileData.Link? in + let title = singleLine(link.label) + guard !title.isEmpty, let url = linkUrl(link.url) else { return nil } + return PubkyProfileData.Link(label: prefix(title, linkTitleMaxLength), url: url) + } + .prefix(linksMaxCount) + ) + return data + } + + private func migrate(_ data: PubkyProfileData, publicKey: String, generation: Int) async -> Paykit.PubkyProfileSnapshot? { + let fitted = Self.fitted(data) + // An avatar already on pubky.app is kept as is; one on Paykit storage is moved below. + let keptImage = data.image.flatMap { Self.isPubkyAppFile($0) ? $0 : nil } + let update = Paykit.PubkyProfileUpdate( + name: fitted.name, + bio: fitted.bio, + image: keptImage, + links: fitted.links.map { Paykit.PubkyProfileLink(title: $0.label, url: $0.url) } + ) + // Recorded before the profile is created without it, so an app stopped before the move still retries it. A failed + // create keeps it too: the create may have landed, and the next load or save handles either case. + if let uri = data.image, Self.isPaykitStorage(uri) { + Self.pubkyAppAvatarPending.insert(publicKey) + } + let snapshot: Paykit.PubkyProfileSnapshot + do { + snapshot = try await admitted(publicKey, generation: generation) { + try await withPubkyAppScope(publicKey) { + try await PubkyService.publishPubkyProfile(update, expectedRevision: nil, expectedIdentity: publicKey) + } + } + Logger.info("Moved Bitkit profile to pubky.app", context: "PubkyAppProfileStore") + } catch { + Logger.warn("Failed to move Bitkit profile to pubky.app: \(error)", context: "PubkyAppProfileStore") + return nil + } + guard let uri = data.image, Self.isPaykitStorage(uri) else { return snapshot } + return await moveAvatar(uri, onto: snapshot, publicKey: publicKey, generation: generation) ?? snapshot + } + + private func moveAvatar( + _ uri: String, + onto snapshot: Paykit.PubkyProfileSnapshot, + publicKey: String, + generation: Int + ) async -> Paykit.PubkyProfileSnapshot? { + do { + let moved = try await admitted(publicKey, generation: generation) { try await withPubkyAppScope(publicKey) { + let bytes = try await PubkyService.fetchFile(uri: uri, maxBytes: Self.avatarMaxBytes) + let image = try await PubkyService.uploadPubkyAppAvatar( + bytes: bytes, + contentType: Self.contentType(for: uri), + expectedIdentity: publicKey + ) + return try await PubkyService.publishPubkyProfile( + Paykit.PubkyProfileUpdate(name: nil, bio: nil, image: image, links: nil), + expectedRevision: snapshot.revision, + expectedIdentity: publicKey + ) + } } + Self.pubkyAppAvatarPending.remove(publicKey) + return moved + } catch { + Logger.warn("Failed to move avatar to pubky.app: \(error)", context: "PubkyAppProfileStore") + Self.pubkyAppAvatarPending.insert(publicKey) + return nil + } + } + + /// Deletes the identity's Bitkit profile copy and, unless `keepPubkyAppProfile`, its pubky.app profile. Writes already + /// under way finish first, and no load or save writes while it runs or, when it started before, after it, so + /// neither profile is written back once deleted. + func deleteProfile(expectedIdentity: String, keepPubkyAppProfile: Bool) async throws { + let key = Self.normalized(expectedIdentity) + writeGate.beginDeletion(key) + defer { writeGate.endDeletion(key) } + if writeGate.hasWritesInFlight(key) { + await withCheckedContinuation { writeGate.onWritesSettled(key, $0) } + } + if !keepPubkyAppProfile, let revision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision { + try await withPubkyAppScope(key) { + try await PubkyService.deletePubkyProfile(expectedRevision: revision, expectedIdentity: key) + } + profilesByRevision[key] = nil + revisionOrder[key] = nil + } + try await PubkyService.deletePaykitProfile(expectedIdentity: key) + } + + /// Runs a write of a load or save that started at `generation`, refused during a deletion or after one that began + /// since. + private func admitted(_ key: String, generation: Int, _ write: () async throws -> T) async throws -> T { + try writeGate.admit(key, generation: generation) + defer { writeGate.end(key) } + return try await write() + } + + private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String, generation: Int) async throws { + do { + try await admitted(expectedIdentity, generation: generation) { + try await PubkyService.publishPaykitProfile( + PubkyProfileData.from(profile: profile).toPaykitProfile(), + expectedIdentity: expectedIdentity + ) + } + } catch { + Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") + throw error + } + } + + /// Sessions from builds before pubky.app profile writes lack its scope; sign in again once to gain it, only while + /// `identity` is still signed in, so a sign-out that lands meanwhile is never undone. + private func withPubkyAppScope(_ identity: String, _ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch let error as PaykitError { + guard case .Identity = error else { throw error } + try await PubkyService.reauthenticate(expectedIdentity: identity) + return try await operation() + } + } + + static func update(from data: PubkyProfileData, base: PubkyProfile?) -> Paykit.PubkyProfileUpdate { + let links = data.links.map { Paykit.PubkyProfileLink(title: $0.label, url: $0.url) } + guard let base else { + return Paykit.PubkyProfileUpdate(name: data.name, bio: data.bio, image: data.image, links: links) + } + let baseLinks = base.links.map { PubkyProfileData.Link(label: $0.label, url: $0.url) } + return Paykit.PubkyProfileUpdate( + name: data.name == base.name ? nil : data.name, + bio: data.bio == base.bio ? nil : data.bio, + // An empty image removes it. + image: data.image == base.imageUrl ? nil : data.image ?? "", + links: data.links == baseLinks ? nil : links + ) + } + + private static func profile(publicKey: String, snapshot: Paykit.PubkyProfileSnapshot, tags: [String]) -> PubkyProfile { + let profile = PubkyProfile(publicKey: publicKey, pubkyProfile: snapshot.profile) + var result = PubkyProfile( + publicKey: publicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl, + links: profile.links, + tags: tags, + status: profile.status + ) + result.revision = snapshot.revision + return result + } + + /// pubky.app keeps profile text on one line and links as full URLs. + private static func normalized(_ data: PubkyProfileData) -> PubkyProfileData { + var data = data + data.name = singleLine(data.name) + data.bio = singleLine(data.bio) + data.links = data.links.map { PubkyProfileData.Link(label: singleLine($0.label), url: linkUrl($0.url) ?? $0.url) } + return data + } + + static func singleLine(_ text: String) -> String { + String(String.UnicodeScalarView(text.unicodeScalars.map { $0.properties.generalCategory == .control ? " " : $0 })) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Truncates by Unicode scalars, as pubky.app counts them. + private static func prefix(_ text: String, _ maxScalars: Int) -> String { + String(String.UnicodeScalarView(text.unicodeScalars.prefix(maxScalars))) + } + + /// Bitkit accepted links without a scheme (a site, an email address, a phone number); pubky.app needs full URLs. + /// Free text that is none of these is not a link pubky.app can hold. + static func linkUrl(_ value: String) -> String? { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, !value.contains(where: \.isWhitespace) || isPhoneNumber(value) else { return nil } + let url: String + if URL(string: value)?.scheme != nil, !isPhoneNumber(value) { + url = value + } else if isEmailAddress(value) { + url = "mailto:\(value)" + } else if isPhoneNumber(value) { + url = "tel:\(value.filter { !" -().".contains($0) })" + } else if value.contains("."), !value.hasPrefix(".") { + url = "https://\(value)" + } else { + return nil + } + guard url.unicodeScalars.count <= linkUrlMaxLength, let parsed = URL(string: url), hasValidHost(parsed) else { return nil } + return url + } + + /// Paykit's URL parser reads a web URL's host that ends in an all-digit label as an IPv4 address (one to four + /// numbers, as in `127.1`) and rejects it when it is not one; other schemes keep such hosts as they are. + private static func hasValidHost(_ url: URL) -> Bool { + guard ["http", "https", "ws", "wss", "ftp"].contains(url.scheme?.lowercased() ?? ""), + let host = url.host, let last = host.split(separator: ".").last, last.allSatisfy(\.isNumber) + else { return true } + let parts = host.split(separator: ".", omittingEmptySubsequences: false).map { UInt64($0) } + guard (1 ... 4).contains(parts.count), parts.allSatisfy({ $0 != nil }) else { return false } + let numbers = parts.compactMap { $0 } + let lastLimit = (UInt64(1) << (8 * UInt64(5 - numbers.count))) - 1 + return numbers.dropLast().allSatisfy { $0 <= 255 } && numbers[numbers.count - 1] <= lastLimit + } + + private static func isEmailAddress(_ value: String) -> Bool { + let parts = value.split(separator: "@", omittingEmptySubsequences: false) + return parts.count == 2 && !parts[0].isEmpty && parts[1].contains(".") && !value.contains("/") + } + + private static func isPhoneNumber(_ value: String) -> Bool { + let digits = value.filter(\.isNumber).count + return value.hasPrefix("+") || digits >= 7 + ? value.allSatisfy { "+0123456789 -().".contains($0) } && digits >= 5 && !value.dropFirst().contains("+") + && value.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) == nil + && value.range(of: #"^\d{1,3}(\.\d{1,3}){3}$"#, options: .regularExpression) == nil + : false + } + + private static func isPaykitStorage(_ uri: String) -> Bool { + uri.contains("/pub/paykit/") + } + + private static func isPubkyAppFile(_ uri: String) -> Bool { + uri.contains("/pub/pubky.app/files/") && uri.unicodeScalars.count <= linkUrlMaxLength + } + + private static func identity(_ expectedIdentity: String?) async throws -> String { + if let expectedIdentity { + return normalized(expectedIdentity) + } + guard let current = await PubkyService.currentPublicKey() else { throw PubkyServiceError.sessionNotActive } + return normalized(current) + } + + private static func isSignedIn(_ publicKey: String) async -> Bool { + guard let current = await PubkyService.currentPublicKey() else { return false } + return PubkyPublicKeyFormat.matches(current, publicKey) + } + + private static func normalized(_ publicKey: String) -> String { + PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + } + + private static func contentType(for uri: String) -> String { + switch uri.split(separator: ".").last?.lowercased() { + case "png": "image/png" + case "webp": "image/webp" + case "gif": "image/gif" + default: "image/jpeg" + } + } +} diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 65fbb6bfb..064d69f06 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -160,15 +160,19 @@ class PubkyProfileManager: ObservableObject { } init( - remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyProfileManager.resolveRemoteProfile(publicKey: $0) }, + remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyAppProfileStore.shared.load(publicKey: $0) }, profilePublisher: @escaping ProfilePublisher = { - try await PubkyService.publishPaykitProfile($0.toPaykitProfile(), expectedIdentity: $1) + try await PubkyAppProfileStore.shared.save($0, expectedIdentity: $1) }, avatarUploader: @escaping AvatarUploader = { - try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) + try await PubkyAppProfileStore.shared.uploadAvatar($0, expectedIdentity: $1) }, profileDeleter: @escaping ProfileDeleter = { - try await PubkyService.deletePaykitProfile(expectedIdentity: $0) + // A Ring identity's profile belongs to Ring, so unlinking it leaves its pubky.app profile in place. + try await PubkyAppProfileStore.shared.deleteProfile( + expectedIdentity: $0, + keepPubkyAppProfile: PubkyProfileManager.isRingAdopted(publicKey: $0) + ) } ) { self.remoteProfileResolver = remoteProfileResolver @@ -447,6 +451,14 @@ class PubkyProfileManager: ObservableObject { return try await avatarUploader(imageData, expectedIdentity) } + /// Upload a contact's avatar to Bitkit's own Paykit blob storage, which contact edits keep using. Returns the `pubky://` + /// URI, and writes nothing unless `expectedIdentity` is still signed in when it runs. + func uploadContactAvatar(image: UIImage, expectedIdentity: String) async throws -> String { + _ = try activeSessionSecret() + let imageData = try compressAvatar(image) + return try await PubkyService.uploadProfileAvatar(bytes: imageData, contentType: "image/jpeg", expectedIdentity: expectedIdentity) + } + private func compressAvatar(_ image: UIImage, maxSize: CGFloat = 400) throws -> Data { // Resize to max dimensions let scale = min(maxSize / image.size.width, maxSize / image.size.height, 1.0) @@ -472,6 +484,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String] = [], existingImageUrl: String? = nil, + baseRevision: String? = nil, avatarImage: UIImage? = nil, loadStoredSecretKey: () async throws -> String? = { await Task.detached { PubkyProfileManager.activeSecretKeyHex() }.value @@ -487,6 +500,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, existingImageUrl: existingImageUrl, + baseRevision: baseRevision, avatarImage: avatarImage ) return @@ -521,6 +535,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, existingImageUrl: existingImageUrl, + baseRevision: baseRevision, avatarImage: avatarImage ) }, @@ -739,6 +754,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], existingImageUrl: String?, + baseRevision: String?, avatarImage: UIImage? ) async throws { var avatarUri: String? @@ -747,10 +763,22 @@ class PubkyProfileManager: ObservableObject { } let imageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - try await writeProfile(name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) + // A profile whose only miss is its tags is created all the same; its tags can be added from Profile. + let (savedProfile, tagsNotSavedAt) = try await writeProfile( + name: name, + bio: bio, + imageUrl: imageUrl, + links: links, + tags: tags, + baseRevision: baseRevision, + expectedIdentity: publicKey + ) + if tagsNotSavedAt != nil { + Logger.warn("Created the profile without its tags", context: "PubkyProfileManager") + } Self.notifyAppStateBackupChanged() - let createdProfile = PubkyProfile( + let createdProfile = savedProfile ?? PubkyProfile( publicKey: publicKey, name: name, bio: bio, @@ -879,11 +907,22 @@ class PubkyProfileManager: ObservableObject { bio: String, links: [PubkyProfileLink], tags: [String] = [], - avatarImage: UIImage? = nil + avatarImage: UIImage? = nil, + baseRevision: String? = nil, + baseTagsUnread: Bool = false ) async throws -> Bool { guard let session = currentSession else { return false } _ = try activeSessionSecret() - return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) + return try await saveProfileEdit( + name: name, + bio: bio, + links: links, + tags: tags, + avatarImage: avatarImage, + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, + session: session + ) } /// Starting the edit drops every profile read that started before it, ahead of the avatar upload, so a refresh that finds @@ -899,6 +938,8 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], avatarImage: UIImage?, + baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { if profileReadAwaitingSaves?.session != session { @@ -913,6 +954,8 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, avatarImage: avatarImage, + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, session: session ) profileSavesInFlight -= 1 @@ -930,6 +973,8 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], avatarImage: UIImage?, + baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { var newImageUrl: String? @@ -948,7 +993,16 @@ class PubkyProfileManager: ObservableObject { return false } } - return try await publishProfileEdit(name: name, bio: bio, links: links, tags: tags, newImageUrl: newImageUrl, session: session) + return try await publishProfileEdit( + name: name, + bio: bio, + links: links, + tags: tags, + newImageUrl: newImageUrl, + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, + session: session + ) } /// A published profile ends any pending setup. The SDK publishes only while the session's identity is still signed in, @@ -960,17 +1014,23 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], newImageUrl: String?, + baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) + let savedProfile: PubkyProfile? + let tagsNotSavedAt: String? do { - try await writeProfile( + (savedProfile, tagsNotSavedAt) = try await writeProfile( name: name, bio: bio, imageUrl: resolvedImageUrl, links: links, tags: tags, + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, expectedIdentity: session.publicKey ) } catch PubkyServiceError.identityChanged { @@ -985,7 +1045,7 @@ class PubkyProfileManager: ObservableObject { return false } - let updatedProfile = PubkyProfile( + let updatedProfile = savedProfile ?? PubkyProfile( publicKey: session.publicKey, name: name, bio: bio, @@ -996,6 +1056,10 @@ class PubkyProfileManager: ObservableObject { ) commitProfile(updatedProfile) setProfileSetupPending(false) + // The other fields are on pubky.app, so the screen shows them and a retry starts from their revision. + if let tagsNotSavedAt { + throw PubkyAppProfileError.tagsNotSaved(revision: tagsNotSavedAt) + } return true } @@ -1068,20 +1132,32 @@ class PubkyProfileManager: ObservableObject { imageUrl: String?, links: [PubkyProfileLink], tags: [String] = [], + baseRevision: String? = nil, + baseTagsUnread: Bool = false, expectedIdentity: String? = nil - ) async throws { - let profileData = PubkyProfileData( + ) async throws -> (profile: PubkyProfile?, tagsNotSavedAt: String?) { + var profileData = PubkyProfileData( name: name, bio: bio, image: imageUrl, links: links.map { PubkyProfileData.Link(label: $0.label, url: $0.url) }, tags: tags ) + profileData.baseRevision = baseRevision + profileData.baseTagsUnread = baseTagsUnread + let saveId = UUID() + profileData.saveId = saveId let publish = profilePublisher - try await Task.detached { - try await publish(profileData, expectedIdentity) - }.value + do { + try await Task.detached { + try await publish(profileData, expectedIdentity) + }.value + } catch let PubkyAppProfileError.tagsNotSaved(revision) { + // The other fields are on pubky.app; the caller decides what that means for its flow. + return await (PubkyAppProfileStore.shared.savedProfile(saveId: saveId), revision) + } + return await (PubkyAppProfileStore.shared.savedProfile(saveId: saveId), nil) } private func discardAbandonedSession() async { @@ -1156,7 +1232,16 @@ class PubkyProfileManager: ObservableObject { avatarImage: UIImage? = nil ) async throws -> Bool { guard let session = currentSession else { return false } - return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) + return try await saveProfileEdit( + name: name, + bio: bio, + links: links, + tags: tags, + avatarImage: avatarImage, + baseRevision: profile?.revision, + baseTagsUnread: profile?.tagsUnread ?? false, + session: session + ) } func clearAuthenticatedStateForTesting() { @@ -1188,6 +1273,15 @@ class PubkyProfileManager: ObservableObject { readOnlyProfile = nil cacheProfileMetadata(loadedProfile) } + } catch PubkyServiceError.profileNotFound { + // A profile deleted on pubky.app is set up again rather than left unreachable. + guard publicKey == pk, profileWriteGeneration == read.generation else { return } + Logger.info("Profile no longer exists, starting profile setup", context: "PubkyProfileManager") + profile = nil + if PubkyPublicKeyFormat.matches(cachedProfileOwner, pk) { + clearCachedProfileMetadata() + } + setProfileSetupPending(true) } catch { Logger.error("Failed to load profile: \(error)", context: "PubkyProfileManager") } @@ -1746,6 +1840,10 @@ class PubkyProfileManager: ObservableObject { /// The signed-in pubky is Pubky Ring's, adopted through the shared keychain, so removing it here only unlinks it. var isRingAdoptedIdentity: Bool { + Self.isRingAdopted(publicKey: publicKey) + } + + nonisolated static func isRingAdopted(publicKey: String?) -> Bool { guard let adopted = AdoptedPubkyReference.current, adopted.sourceApp == SharedPubkyKeychain.ringSourceApp else { return false } diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index 64067dcc7..7ac301090 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -9,6 +9,16 @@ struct PubkyProfileData: Codable, Equatable { var image: String? var links: [Link] var tags: [String] + /// The pubky.app revision an edit started from; never part of the published copy. + var baseRevision: String? + /// Whether the profile the edit started from lacked the stored tags, as the copy could not be read; never published. + var baseTagsUnread = false + /// Identifies the save, so its caller can read back the profile it stored; never part of the published copy. + var saveId: UUID? + + private enum CodingKeys: String, CodingKey { + case name, bio, image, links, tags + } struct Link: Codable, Equatable { let label: String @@ -115,6 +125,17 @@ struct PubkyProfile { let links: [PubkyProfileLink] let tags: [String] let status: String? + /// The pubky.app revision this profile was read or saved at, for a save to check against. + var revision: String? + /// Set when Bitkit's copy could not be read, so `tags` are not the stored ones and a save keeps those. + var tagsUnread = false + + func withImageUrl(_ imageUrl: String?) -> PubkyProfile { + var profile = PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + profile.revision = revision + profile.tagsUnread = tagsUnread + return profile + } var truncatedPublicKey: String { Self.truncate(publicKey) @@ -185,7 +206,10 @@ struct PubkyProfile { } func withTags(_ tags: [String]) -> PubkyProfile { - PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + var profile = PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + profile.revision = revision + profile.tagsUnread = tagsUnread + return profile } func withNameFallback(_ fallbackName: String?) -> PubkyProfile { @@ -213,3 +237,23 @@ struct PubkyProfile { return PubkyPublicKeyFormat.normalized(trimmedKey) ?? (trimmedKey.hasPrefix("pubky") ? trimmedKey : "pubky\(trimmedKey)") } } + +// MARK: - pubky.app profile limits + +/// Limits pubky-app-specs sets on the pubky.app profile. +enum PubkyAppProfileLimits { + static let nameLength = 3 ... 50 + static let bioMaxLength = 160 + static let maxLinks = 5 + + /// pubky.app counts Unicode scalars, not characters. + static func isValidName(_ name: String) -> Bool { + nameLength.contains(name.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars.count) + } + + /// `text` cut to `maxLength` Unicode scalars, or nil when it already fits. + static func capped(_ text: String, to maxLength: Int) -> String? { + guard text.unicodeScalars.count > maxLength else { return nil } + return String(String.UnicodeScalarView(text.unicodeScalars.prefix(maxLength))) + } +} diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index d60006bab..d3b97c62e 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -948,7 +948,9 @@ "profile__pubky_copied" = "تم نسخ pubky إلى الحافظة"; "profile__edit_delete_section" = "حذف"; "profile__edit_unlink_section" = "إلغاء الربط"; -"profile__edit_public_note" = "يرجى ملاحظة أن معلومات الملف الشخصي مخزنة في ملفات عامة."; +"profile__edit_changed_error" = "تغيّر ملفك الشخصي في pubky.app. حمّل Bitkit أحدث نسخة، لذا أعد إجراء تعديلك."; +"profile__edit_public_note" = "هذا هو ملفك الشخصي على pubky.app. إنه عام، والتغييرات التي تجريها هنا تظهر أيضًا على pubky.app."; +"profile__edit_tags_error" = "تعذّر على Bitkit حفظ وسومك. حُفظت تغييراتك الأخرى، لذا حاول حفظ الوسوم مرة أخرى."; "profile__delete_title" = "حذف الملف الشخصي؟"; "profile__delete_description" = "يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا."; "profile__delete_confirm" = "نعم، احذف"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index c8db7bac5..ba9974865 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1064,7 +1064,9 @@ "profile__pubky_copied" = "pubky copiat al porta-retalls"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Tingues en compte que la informació del perfil es desa en fitxers públics."; +"profile__edit_changed_error" = "El teu perfil ha canviat a pubky.app. Bitkit ha carregat la versió més recent, així que torna a fer la teva edició."; +"profile__edit_public_note" = "Aquest és el teu perfil de pubky.app. És públic, i els canvis que hi facis aquí també es veuen a pubky.app."; +"profile__edit_tags_error" = "Bitkit no ha pogut desar les teves etiquetes. Els altres canvis s'han desat, així que torna a provar amb les etiquetes."; "profile__delete_title" = "Eliminar el perfil?"; "profile__delete_description" = "Eliminar el teu perfil l'esborra de Pubky. És possible que els contactes conservin la informació de perfil i de pagament que s'hi hagi compartit abans."; "profile__delete_confirm" = "Sí, elimina"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 05c8fd16c..68d50fc79 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1169,7 +1169,9 @@ "profile__pubky_copied" = "pubky zkopírován do schránky"; "profile__edit_delete_section" = "SMAZAT"; "profile__edit_unlink_section" = "ZRUŠIT PROPOJENÍ"; -"profile__edit_public_note" = "Upozorňujeme, že informace o profilu jsou uloženy ve veřejných souborech."; +"profile__edit_changed_error" = "Tvůj profil se v pubky.app změnil. Bitkit načetl nejnovější verzi, proveď proto úpravu znovu."; +"profile__edit_public_note" = "Toto je tvůj profil na pubky.app. Je veřejný a změny, které zde provedeš, se zobrazí i na pubky.app."; +"profile__edit_tags_error" = "Bitkit nemohl uložit vaše štítky. Ostatní změny byly uloženy, tak zkuste štítky znovu."; "profile__delete_title" = "Smazat profil?"; "profile__delete_description" = "Smazáním profilu ho odstraníte z Pubky. Kontakty si mohou ponechat informace o profilu a platbách, které jste s nimi dříve sdíleli."; "profile__delete_confirm" = "Ano, smazat"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 1e18ebc02..838d90da8 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1167,7 +1167,9 @@ "profile__pubky_copied" = "pubky in die Zwischenablage kopiert"; "profile__edit_delete_section" = "LÖSCHEN"; "profile__edit_unlink_section" = "VERKNÜPFUNG AUFHEBEN"; -"profile__edit_public_note" = "Bitte beachte, dass Profilinformationen in öffentlichen Dateien gespeichert werden."; +"profile__edit_changed_error" = "Dein Profil wurde in pubky.app geändert. Bitkit hat die neueste Version geladen, nimm deine Änderung bitte erneut vor."; +"profile__edit_public_note" = "Das ist dein pubky.app-Profil. Es ist öffentlich, und Änderungen, die du hier vornimmst, erscheinen auch auf pubky.app."; +"profile__edit_tags_error" = "Bitkit konnte deine Tags nicht speichern. Deine anderen Änderungen wurden gespeichert, versuche es mit den Tags erneut."; "profile__delete_title" = "Profil löschen?"; "profile__delete_description" = "Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten."; "profile__delete_confirm" = "Ja, löschen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index d61213441..a5a2d0928 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -965,7 +965,9 @@ "profile__pubky_copied" = "Το pubky αντιγράφηκε στο πρόχειρο"; "profile__edit_delete_section" = "ΔΙΑΓΡΑΦΗ"; "profile__edit_unlink_section" = "ΑΠΟΣΥΝΔΕΣΗ"; -"profile__edit_public_note" = "Τα στοιχεία του προφίλ αποθηκεύονται σε δημόσια αρχεία."; +"profile__edit_changed_error" = "Το προφίλ σου άλλαξε στο pubky.app. Το Bitkit φόρτωσε την πιο πρόσφατη έκδοση, οπότε κάνε ξανά την αλλαγή σου."; +"profile__edit_public_note" = "Αυτό είναι το προφίλ σου στο pubky.app. Είναι δημόσιο, και οι αλλαγές που κάνεις εδώ εμφανίζονται και στο pubky.app."; +"profile__edit_tags_error" = "Το Bitkit δεν μπόρεσε να αποθηκεύσει τις ετικέτες σου. Οι άλλες αλλαγές σου αποθηκεύτηκαν, οπότε δοκίμασε ξανά τις ετικέτες."; "profile__delete_title" = "Διαγραφή Προφίλ;"; "profile__delete_description" = "Η διαγραφή του προφίλ σας το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα."; "profile__delete_confirm" = "Ναι, Διαγραφή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 6a7b132fb..ad2f758e5 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1182,7 +1182,9 @@ "profile__pubky_copied" = "pubky copied to clipboard"; "profile__edit_delete_section" = "DELETE"; "profile__edit_unlink_section" = "UNLINK"; -"profile__edit_public_note" = "Please note profile information is stored in public files."; +"profile__edit_changed_error" = "Your profile changed in pubky.app. Bitkit loaded the latest version, so make your edit again."; +"profile__edit_public_note" = "This is your pubky.app profile. It is public, and changes you make here also show on pubky.app."; +"profile__edit_tags_error" = "Bitkit could not save your tags. Your other changes were saved, so try the tags again."; "profile__delete_title" = "Delete Profile?"; "profile__delete_description" = "Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them."; "profile__delete_confirm" = "Yes, Delete"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 34f87b47c..0693ea303 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1172,7 +1172,9 @@ "profile__pubky_copied" = "pubky copiado al portapapeles"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__edit_changed_error" = "Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio."; +"profile__edit_public_note" = "Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app."; +"profile__edit_tags_error" = "Bitkit no pudo guardar tus etiquetas. Tus otros cambios se guardaron, así que vuelve a intentar con las etiquetas."; "profile__delete_title" = "¿Eliminar perfil?"; "profile__delete_description" = "Al eliminar su perfil, se borra de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 874dd2b49..ae855a8fc 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1081,7 +1081,9 @@ "profile__pubky_copied" = "pubky copiado al portapapeles"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__edit_changed_error" = "Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio."; +"profile__edit_public_note" = "Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app."; +"profile__edit_tags_error" = "Bitkit no pudo guardar tus etiquetas. Tus otros cambios se guardaron, así que vuelve a intentar con las etiquetas."; "profile__delete_title" = "¿Eliminar el perfil?"; "profile__delete_description" = "Al eliminar su perfil, se elimina de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 0952bf0ff..9bbdec082 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1180,7 +1180,9 @@ "profile__pubky_copied" = "pubky copié dans le presse-papiers"; "profile__edit_delete_section" = "SUPPRIMER"; "profile__edit_unlink_section" = "DISSOCIER"; -"profile__edit_public_note" = "Veuillez noter que les informations de profil sont stockées dans des fichiers publics."; +"profile__edit_changed_error" = "Ton profil a changé sur pubky.app. Bitkit a chargé la version la plus récente, refais donc ta modification."; +"profile__edit_public_note" = "Voici ton profil pubky.app. Il est public, et les modifications que tu fais ici apparaissent aussi sur pubky.app."; +"profile__edit_tags_error" = "Bitkit n'a pas pu enregistrer vos étiquettes. Vos autres modifications ont été enregistrées, réessayez pour les étiquettes."; "profile__delete_title" = "Supprimer le profil ?"; "profile__delete_description" = "La suppression de votre profil le retire de Pubky. Vos contacts peuvent encore conserver les informations de profil et de paiement que vous leur avez partagées."; "profile__delete_confirm" = "Oui, supprimer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 4b9336103..39d2bf90b 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1146,7 +1146,9 @@ "profile__pubky_copied" = "pubky copiato negli appunti"; "profile__edit_delete_section" = "ELIMINA"; "profile__edit_unlink_section" = "SCOLLEGA"; -"profile__edit_public_note" = "Tieni presente che le informazioni del profilo sono salvate in file pubblici."; +"profile__edit_changed_error" = "Il tuo profilo è cambiato su pubky.app. Bitkit ha caricato la versione più recente, quindi ripeti la modifica."; +"profile__edit_public_note" = "Questo è il tuo profilo pubky.app. È pubblico e le modifiche che fai qui compaiono anche su pubky.app."; +"profile__edit_tags_error" = "Bitkit non è riuscito a salvare i tuoi tag. Le altre modifiche sono state salvate, quindi riprova con i tag."; "profile__delete_title" = "Eliminare il Profilo?"; "profile__delete_description" = "Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro."; "profile__delete_confirm" = "Sì, Elimina"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 440f8b961..785d8acf3 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1175,7 +1175,9 @@ "profile__pubky_copied" = "pubky gekopieerd naar klembord"; "profile__edit_delete_section" = "VERWIJDEREN"; "profile__edit_unlink_section" = "ONTKOPPELEN"; -"profile__edit_public_note" = "Let op: profielgegevens worden opgeslagen in openbare bestanden."; +"profile__edit_changed_error" = "Je profiel is gewijzigd in pubky.app. Bitkit heeft de nieuwste versie geladen, dus voer je wijziging opnieuw uit."; +"profile__edit_public_note" = "Dit is je pubky.app-profiel. Het is openbaar, en wijzigingen die je hier maakt, verschijnen ook op pubky.app."; +"profile__edit_tags_error" = "Bitkit kon je tags niet opslaan. Je andere wijzigingen zijn opgeslagen, dus probeer de tags opnieuw."; "profile__delete_title" = "Profiel verwijderen?"; "profile__delete_description" = "Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren."; "profile__delete_confirm" = "Ja, verwijderen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 8a67c9ccb..429103279 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1179,7 +1179,9 @@ "profile__pubky_copied" = "pubky skopiowany do schowka"; "profile__edit_delete_section" = "USUWANIE"; "profile__edit_unlink_section" = "ODŁĄCZANIE"; -"profile__edit_public_note" = "Pamiętaj, że dane profilu są przechowywane w publicznych plikach."; +"profile__edit_changed_error" = "Twój profil zmienił się w pubky.app. Bitkit wczytał najnowszą wersję, więc wprowadź zmianę ponownie."; +"profile__edit_public_note" = "To jest Twój profil pubky.app. Jest publiczny, a zmiany wprowadzone tutaj pojawią się też w pubky.app."; +"profile__edit_tags_error" = "Bitkit nie mógł zapisać Twoich tagów. Pozostałe zmiany zostały zapisane, więc spróbuj ponownie z tagami."; "profile__delete_title" = "Usunąć profil?"; "profile__delete_description" = "Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności."; "profile__delete_confirm" = "Tak, usuń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 3051d7554..ed2a2839a 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1180,7 +1180,9 @@ "profile__pubky_copied" = "pubky copiada para a área de transferência"; "profile__edit_delete_section" = "EXCLUIR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Observe que as informações do perfil são armazenadas em arquivos públicos."; +"profile__edit_changed_error" = "Seu perfil mudou no pubky.app. O Bitkit carregou a versão mais recente, então faça sua alteração de novo."; +"profile__edit_public_note" = "Este é o seu perfil do pubky.app. Ele é público, e as alterações que você faz aqui também aparecem no pubky.app."; +"profile__edit_tags_error" = "O Bitkit não conseguiu salvar suas tags. Suas outras alterações foram salvas, então tente as tags novamente."; "profile__delete_title" = "Excluir Perfil?"; "profile__delete_description" = "Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index c04fea233..8197786d0 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -940,7 +940,9 @@ "profile__pubky_copied" = "pubky copiado para a área de transferência"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESASSOCIAR"; -"profile__edit_public_note" = "Tenha em atenção que as informações do perfil são guardadas em ficheiros públicos."; +"profile__edit_changed_error" = "O teu perfil mudou no pubky.app. A Bitkit carregou a versão mais recente, por isso faz novamente a tua alteração."; +"profile__edit_public_note" = "Este é o teu perfil do pubky.app. É público, e as alterações que fazes aqui também aparecem no pubky.app."; +"profile__edit_tags_error" = "O Bitkit não conseguiu guardar as suas etiquetas. As outras alterações foram guardadas, por isso tente as etiquetas novamente."; "profile__delete_title" = "Eliminar perfil?"; "profile__delete_description" = "Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 06cfb4fbd..5e228f7d4 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1165,7 +1165,9 @@ "profile__pubky_copied" = "pubky скопирован в буфер обмена"; "profile__edit_delete_section" = "УДАЛЕНИЕ"; "profile__edit_unlink_section" = "ОТВЯЗКА"; -"profile__edit_public_note" = "Обратите внимание: данные профиля хранятся в публичных файлах."; +"profile__edit_changed_error" = "Ваш профиль изменился в pubky.app. Bitkit загрузил последнюю версию, поэтому внесите изменение снова."; +"profile__edit_public_note" = "Это ваш профиль pubky.app. Он публичный, и изменения, которые вы вносите здесь, также видны в pubky.app."; +"profile__edit_tags_error" = "Bitkit не удалось сохранить ваши теги. Остальные изменения сохранены, поэтому попробуйте сохранить теги ещё раз."; "profile__delete_title" = "Удалить Профиль?"; "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index a19399c70..6e91af98d 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -321,6 +321,12 @@ enum PubkyService { return result.sessionAccess.exportSessionSecret() } + /// Signs in again with the stored key, to gain a scope an older session lacks, only while `expectedIdentity` is signed in + /// and the key is its own. See `PaykitSdkService.reauthenticate(expectedIdentity:)`. + static func reauthenticate(expectedIdentity: String) async throws { + _ = try await PaykitSdkService.shared.reauthenticate(expectedIdentity: expectedIdentity) + } + // MARK: - File Fetching /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution. @@ -360,6 +366,38 @@ enum PubkyService { try await PaykitSdkService.shared.deletePaykitProfile(expectedIdentity: expectedIdentity) } + static func fetchPubkyProfileWithRevision(publicKey: String) async throws -> Paykit.PubkyProfileSnapshot? { + try await PaykitSdkService.shared.fetchPubkyProfileWithRevision(publicKey: publicKey) + } + + static func fetchPaykitProfile(publicKey: String) async throws -> Paykit.PaykitProfileRecord? { + try await PaykitSdkService.shared.fetchPaykitProfile(publicKey: publicKey) + } + + static func publishPubkyProfile( + _ update: Paykit.PubkyProfileUpdate, + expectedRevision: String?, + expectedIdentity: String + ) async throws -> Paykit.PubkyProfileSnapshot { + try await PaykitSdkService.shared.publishPubkyProfile( + update, + expectedRevision: expectedRevision, + expectedIdentity: expectedIdentity + ) + } + + static func deletePubkyProfile(expectedRevision: String, expectedIdentity: String) async throws { + try await PaykitSdkService.shared.deletePubkyProfile(expectedRevision: expectedRevision, expectedIdentity: expectedIdentity) + } + + static func uploadPubkyAppAvatar(bytes: Data, contentType: String, expectedIdentity: String) async throws -> String { + try await PaykitSdkService.shared.uploadPubkyAppAvatar( + bytes: bytes, + contentType: contentType, + expectedIdentity: expectedIdentity + ) + } + // MARK: - Contacts static func getContacts(publicKey: String) async throws -> [String] { @@ -680,7 +718,7 @@ actor PaykitSdkService { localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) markWalletBackupDataChanged() @@ -699,7 +737,7 @@ actor PaykitSdkService { localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) return PubkyRegisteredIdentity(result: result, walletGeneration: generation) } @@ -719,11 +757,27 @@ actor PaykitSdkService { } } + /// Signs in again with the stored key only while `expectedIdentity` is signed in and the key is its own, both checked in + /// the same locked operation as the sign-in, so a sign-out or another identity's sign-in that lands first is never + /// undone and throws `identityChanged`. + func reauthenticate( + expectedIdentity: String, + secretKeyHex: () -> String? = { PubkyProfileManager.activeSecretKeyHex() } + ) async throws -> PubkySessionBootstrapResult { + try await operationLock.withLock { + try await Self.requireSignedInIdentity(expectedIdentity, in: handle()) + guard let secretKeyHex = secretKeyHex(), + PubkyPublicKeyFormat.matches(try? PubkyProfileManager.publicKeyFromSecretKey(secretKeyHex), expectedIdentity) + else { throw PubkyServiceError.identityChanged } + return try await signInLocked(secretKeyHex: secretKeyHex) + } + } + private func signInLocked(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { let previousPublicKey = try await cachedIdentityPublicKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) markWalletBackupDataChanged() @@ -831,6 +885,42 @@ actor PaykitSdkService { } } + func fetchPubkyProfileWithRevision(publicKey: String) async throws -> Paykit.PubkyProfileSnapshot? { + try await withPublicRead { try await $0.fetchPubkyProfileWithRevision(publicKey: publicKey) } + } + + func fetchPaykitProfile(publicKey: String) async throws -> Paykit.PaykitProfileRecord? { + try await withPublicRead { try await $0.fetchPaykitProfile(publicKey: publicKey) } + } + + /// Writes only while `expectedIdentity` is signed in, checked in the same locked operation as the write. + func publishPubkyProfile( + _ update: Paykit.PubkyProfileUpdate, + expectedRevision: String?, + expectedIdentity: String + ) async throws -> Paykit.PubkyProfileSnapshot { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + return try await sdk.publishPubkyProfile(update: update, expectedRevision: expectedRevision) + } + } + + /// Deletes the pubky.app profile only while `expectedIdentity` is signed in. + func deletePubkyProfile(expectedRevision: String, expectedIdentity: String) async throws { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + try await sdk.deletePubkyProfile(expectedRevision: expectedRevision) + } + } + + /// Uploads a pubky.app avatar blob and file only while `expectedIdentity` is signed in, and returns the file URI. + func uploadPubkyAppAvatar(bytes: Data, contentType: String, expectedIdentity: String) async throws -> String { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + return try await sdk.uploadPubkyAppAvatar(bytes: bytes, contentType: contentType) + } + } + func fetchPubkyFollows(publicKey: String) async throws -> [String] { try await withPublicRead { try await $0.fetchPubkyFollows(publicKey: publicKey, maxEntries: 10000) } } @@ -1608,10 +1698,16 @@ actor PaykitSdkService { secretKey.exportBytes().hex } + /// Scope a restored session must hold. Sessions from builds before pubky.app profile writes have only this. nonisolated static func requiredCapabilities() throws -> String { Paykit.paykitAuthorizerSessionCapabilities() } + /// Scope for sessions Bitkit creates: Paykit plus the shared pubky.app profile and its avatar. + nonisolated static func sessionCapabilities() throws -> String { + try "\(requiredCapabilities()),\(Paykit.pubkyAppProfileSessionCapabilities())" + } + private func handle() throws -> PaykitSdk { if let sdk { return sdk diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 5f5603824..370869ee8 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -160,7 +160,7 @@ struct EditContactView: View { ) { fillFormFromContact() let uploadedImageUrl = if let avatarImage { - try await pubkyProfile.uploadAvatar(image: avatarImage, expectedIdentity: session.publicKey) + try await pubkyProfile.uploadContactAvatar(image: avatarImage, expectedIdentity: session.publicKey) } else { form.imageUrl } diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 42119d49f..63ec3a939 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -37,6 +37,11 @@ struct CreateProfileView: View { .task { await loadInitialData() } + .onChange(of: username) { _, value in + if let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.nameLength.upperBound) { + username = capped + } + } } // MARK: - Form Content @@ -64,7 +69,7 @@ struct CreateProfileView: View { ) { await saveProfile() } - .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!PubkyAppProfileLimits.isValidName(username)) .accessibilityIdentifier("CreateProfileSave") .padding(.top, 16) .padding(.horizontal, 16) @@ -169,6 +174,9 @@ struct CreateProfileView: View { username = remote.name existingProfile = remote isRestoring = true + } else { + existingProfile = nil + isRestoring = false } } catch is CancellationError { return @@ -187,7 +195,7 @@ struct CreateProfileView: View { } do { - let profile = try await PubkyProfileManager.resolveRemoteProfile(publicKey: publicKey) + let profile = try await PubkyAppProfileStore.shared.load(publicKey: publicKey) remoteLookupFailed = false return profile } catch PubkyServiceError.profileNotFound { @@ -213,7 +221,7 @@ struct CreateProfileView: View { } let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } + guard PubkyAppProfileLimits.isValidName(trimmedName) else { return } isSaving = true defer { isSaving = false } @@ -225,11 +233,15 @@ struct CreateProfileView: View { links: existingProfile?.links ?? [], tags: existingProfile?.tags ?? [], existingImageUrl: existingProfile?.imageUrl, + baseRevision: existingProfile?.revision, avatarImage: avatarImage ) navigation.navigate(.payContacts) } catch is CancellationError { return + } catch PubkyAppProfileError.changedElsewhere { + app.toast(type: .error, title: t("profile__create_error_title"), description: PubkyAppProfileError.changedElsewhere.localizedDescription) + await loadInitialData() } catch { Logger.error("Failed to save profile: \(error)", context: "CreateProfileView") app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 183393e11..431a43b8e 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -11,6 +11,9 @@ struct EditProfileView: View { @State private var bio: String = "" @State private var links: [ProfileLinkInput] = [] @State private var tags: [String] = [] + @State private var baseRevision: String? + @State private var baseTagsUnread = false + @State private var loadedBio = "" @State private var isSaving = false @State private var isDeleting = false @State private var showDeleteConfirmation = false @@ -32,6 +35,8 @@ struct EditProfileView: View { publicKeyLabel: t("profile__create_pubky_display_label"), bioPlaceholder: t("profile__create_bio_placeholder"), isSaving: isSaving, + maxLinks: PubkyAppProfileLimits.maxLinks, + isNameValid: PubkyAppProfileLimits.isValidName, footerNote: t("profile__edit_public_note"), deleteLabel: copy.label, deleteSectionCaption: copy.sectionCaption, @@ -60,6 +65,16 @@ struct EditProfileView: View { .task { loadProfileData() } + .onChange(of: username) { _, value in + if let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.nameLength.upperBound) { + username = capped + } + } + .onChange(of: bio) { _, value in + // A longer bio stored before the limit is shown in full; only an edit is cut. + guard value != loadedBio, let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.bioMaxLength) else { return } + bio = capped + } .alert(copy.title, isPresented: $showDeleteConfirmation) { Button(copy.confirm, role: .destructive) { Task { await deleteProfile() } @@ -140,9 +155,12 @@ struct EditProfileView: View { private func loadProfileData() { guard let profile = pubkyProfile.profile else { return } username = profile.name + loadedBio = profile.bio bio = profile.bio links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } tags = profile.tags + baseRevision = profile.revision + baseTagsUnread = profile.tagsUnread } // MARK: - Delete Profile @@ -216,7 +234,7 @@ struct EditProfileView: View { /// or the profile publishes, it stops quietly, with no toast and no navigation. private func saveProfile() async { let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } + guard PubkyAppProfileLimits.isValidName(trimmedName) else { return } isSaving = true defer { isSaving = false } @@ -227,7 +245,9 @@ struct EditProfileView: View { bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, tags: tags, - avatarImage: avatarImage + avatarImage: avatarImage, + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread ) guard isSaved else { return } app.toast(type: .success, title: t("profile__edit_saved"), accessibilityIdentifier: "ProfileUpdatedToast") @@ -235,6 +255,14 @@ struct EditProfileView: View { } catch { Logger.error("Failed to save profile: \(error)", context: "EditProfileView") app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + if case PubkyAppProfileError.changedElsewhere = error { + await pubkyProfile.loadProfile() + avatarImage = nil + loadProfileData() + } else if case let PubkyAppProfileError.tagsNotSaved(revision) = error { + avatarImage = nil + baseRevision = revision + } } } } diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 9b3c4100b..e37280c61 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -279,11 +279,16 @@ struct ProfileView: View { name: profile.name, bio: profile.bio, links: profile.links, - tags: tags + tags: tags, + baseRevision: profile.revision, + baseTagsUnread: profile.tagsUnread ) } catch { Logger.error("Failed to update profile tags: \(error)", context: "ProfileView") app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + if case PubkyAppProfileError.changedElsewhere = error { + await pubkyProfile.loadProfile() + } } } } @@ -291,7 +296,10 @@ struct ProfileView: View { private func refreshProfile() async { isRefreshing = true defer { isRefreshing = false } - guard pubkyProfile.profile == nil else { return } + guard pubkyProfile.profile == nil else { + await pubkyProfile.loadProfile() + return + } async let restoration: Void = pubkyProfile.restoreSessionIfNeeded() await pubkyProfile.loadProfile() await restoration diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 95ca4897e..749c4c1b6 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -923,6 +923,39 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { lastEventAt: "2026-10-20T12:00:00Z", invalidReason: nil ) } + + func testReauthenticationSignsInOnlyWhileTheExpectedIdentityIsSignedInWithItsOwnKey() async throws { + let ownKey = String(repeating: "1", count: 64) + let ownIdentity = try PubkyProfileManager.publicKeyFromSecretKey(ownKey) + let otherKey = String(repeating: "2", count: 64) + let signInReached = PubkyServiceError.authFailed("sign-in reached") + let cases: [(signedIn: String?, key: String?, signsIn: Bool)] = [ + (nil, ownKey, false), + (identityB, ownKey, false), + (ownIdentity, otherKey, false), + (ownIdentity, nil, false), + (ownIdentity, ownKey, true), + ] + for testCase in cases { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = testCase.signedIn + var bootstraps = 0 + let service = PaykitSdkService(sdkFactory: { sdk }, bootstrapFactory: { _, _ in + bootstraps += 1 + throw signInReached + }) + + do { + _ = try await service.reauthenticate(expectedIdentity: ownIdentity, secretKeyHex: { testCase.key }) + XCTFail("Expected reauthentication to stop") + } catch PubkyServiceError.identityChanged { + XCTAssertFalse(testCase.signsIn, "\(testCase)") + } catch { + XCTAssertTrue(testCase.signsIn, "\(testCase): \(error)") + } + XCTAssertEqual(bootstraps, testCase.signsIn ? 1 : 0, "\(testCase)") + } + } } private let identityA = "pubky8qd4tbz3hyafi7h7hoqwd5hm7tsaz1n7txcyojmd4kxf9xp7mgro" diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift new file mode 100644 index 000000000..4ed599d29 --- /dev/null +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -0,0 +1,209 @@ +@testable import Bitkit +import XCTest + +final class PubkyAppProfileStoreTests: XCTestCase { + private let base = PubkyProfile( + publicKey: "pubkytest", + name: "Alice", + bio: "Bio", + imageUrl: "pubky://test/pub/pubky.app/files/0000000000000", + links: [PubkyProfileLink(label: "X", url: "https://x.com/alice")], + status: "away" + ) + + private func data(name: String = "Alice", bio: String = "Bio", image: String? = nil, links: [PubkyProfileData.Link]? = nil) + -> PubkyProfileData + { + PubkyProfileData( + name: name, + bio: bio, + image: image ?? base.imageUrl, + links: links ?? [.init(label: "X", url: "https://x.com/alice")], + tags: [] + ) + } + + func testUnchangedFieldsAreLeftOut() { + let update = PubkyAppProfileStore.update(from: data(name: "Alicia"), base: base) + + XCTAssertEqual(update.name, "Alicia") + XCTAssertNil(update.bio) + XCTAssertNil(update.image) + XCTAssertNil(update.links) + } + + func testRemovedImageAndLinksAreCleared() { + var edited = data(links: []) + edited.image = nil + + let update = PubkyAppProfileStore.update(from: edited, base: base) + + XCTAssertEqual(update.image, "") + XCTAssertEqual(update.links?.isEmpty, true) + XCTAssertNil(update.name) + } + + func testWithoutBaseEveryFieldIsWritten() { + let update = PubkyAppProfileStore.update(from: data(), base: nil) + + XCTAssertEqual(update.name, "Alice") + XCTAssertEqual(update.bio, "Bio") + XCTAssertEqual(update.image, base.imageUrl) + XCTAssertEqual(update.links?.first?.title, "X") + } + + func testLinksWithoutSchemeBecomeFullUrls() { + XCTAssertEqual(PubkyAppProfileStore.linkUrl("example.com"), "https://example.com") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("alice@example.com"), "mailto:alice@example.com") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("+49 123 456"), "tel:+49123456") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("https://x.com/alice"), "https://x.com/alice") + XCTAssertNil(PubkyAppProfileStore.linkUrl(" ")) + } + + func testFreeTextIsNotMadeALink() { + XCTAssertNil(PubkyAppProfileStore.linkUrl("my site")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("12345")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("a@b")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("2024-01-01")) + XCTAssertEqual(PubkyAppProfileStore.linkUrl("555.123.4567"), "tel:5551234567") + XCTAssertNil(PubkyAppProfileStore.linkUrl("v1.2.3")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("foo.123")) + XCTAssertEqual(PubkyAppProfileStore.linkUrl("tel://5551234567"), "tel://5551234567") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("https://127.1"), "https://127.1") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("192.168.1.10"), "https://192.168.1.10") + XCTAssertNil(PubkyAppProfileStore.linkUrl("New York")) + XCTAssertEqual(PubkyAppProfileStore.linkUrl("medium.com/@alice"), "https://medium.com/@alice") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("example.com/a@b"), "https://example.com/a@b") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("0123 456 789"), "tel:0123456789") + } + + func testOnlyControlCharactersAreFlattened() { + XCTAssertEqual(PubkyAppProfileStore.singleLine("Builder\nMaker"), "Builder Maker") + XCTAssertEqual(PubkyAppProfileStore.singleLine("👩\u{200D}💻 می\u{200C}خواهم"), "👩\u{200D}💻 می\u{200C}خواهم") + } + + func testCapsCountUnicodeScalars() { + XCTAssertNil(PubkyAppProfileLimits.capped("abc", to: 3)) + XCTAssertEqual(PubkyAppProfileLimits.capped("👩\u{200D}💻x", to: 3), "👩\u{200D}💻") + XCTAssertFalse(PubkyAppProfileLimits.isValidName("é\u{301}")) + XCTAssertTrue(PubkyAppProfileLimits.isValidName("e\u{301}e")) + } + + func testProfileWithoutBaseIsFittedToPubkyAppLimits() { + let links = (1 ... 6).map { PubkyProfileData.Link(label: "Site \($0)", url: "site\($0).com") } + + [.init(label: "City", url: "New York")] + let fitted = PubkyAppProfileStore.fitted( + PubkyProfileData( + name: "Alice", + bio: String(repeating: "b", count: 200), + image: nil, + links: [.init(label: "City", url: "New York")] + links, + tags: [] + ) + ) + + XCTAssertEqual(fitted.bio.unicodeScalars.count, 160) + XCTAssertEqual(fitted.links.count, 5) + XCTAssertEqual(fitted.links.first?.url, "https://site1.com") + XCTAssertFalse(fitted.links.contains { $0.label == "City" }) + } +} + +final class ProfileWriteGateTests: XCTestCase { + func testWriteFromBeforeADeletionIsRefused() { + var gate = ProfileWriteGate() + let generation = gate.generation("a") + + gate.beginDeletion("a") + gate.endDeletion("a") + + XCTAssertThrowsError(try gate.admit("a", generation: generation)) + XCTAssertNoThrow(try gate.admit("a", generation: gate.generation("a"))) + } + + func testNoWriteIsAdmittedDuringADeletion() { + var gate = ProfileWriteGate() + + gate.beginDeletion("a") + let startedDuring = gate.generation("a") + + XCTAssertThrowsError(try gate.admit("a", generation: startedDuring)) + gate.endDeletion("a") + XCTAssertThrowsError(try gate.admit("a", generation: startedDuring)) + XCTAssertNoThrow(try gate.admit("b", generation: gate.generation("b"))) + } + + func testCopyRefreshFromBeforeASaveIsRefused() { + var gate = ProfileWriteGate() + let readBeforeSave = gate.copyEpoch("a") + + gate.beginSave("a") + gate.endSave("a") + + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readBeforeSave)) + XCTAssertTrue(gate.mayRefreshCopy("a", epoch: gate.copyEpoch("a"))) + } + + func testNoCopyRefreshDuringASave() { + var gate = ProfileWriteGate() + + gate.beginSave("a") + let readDuringSave = gate.copyEpoch("a") + + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSave)) + gate.endSave("a") + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSave)) + XCTAssertTrue(gate.mayRefreshCopy("b", epoch: gate.copyEpoch("b"))) + } + + func testNoCopyRefreshDuringOverlappingSaves() { + var gate = ProfileWriteGate() + + gate.beginSave("a") + gate.beginSave("a") + let readDuringSaves = gate.copyEpoch("a") + + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSaves)) + gate.endSave("a") + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSaves)) + gate.endSave("a") + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSaves)) + XCTAssertTrue(gate.mayRefreshCopy("a", epoch: gate.copyEpoch("a"))) + } + + func testDeletionWaitsForWritesUnderWay() async throws { + actor Box { + var gate = ProfileWriteGate() + var settled = false + func admit() throws { + try gate.admit("a", generation: gate.generation("a")) + } + + func end() { + gate.end("a") + } + + func delete() async { + gate.beginDeletion("a") + if gate.hasWritesInFlight("a") { + await withCheckedContinuation { gate.onWritesSettled("a", $0) } + } + settled = true + } + } + let box = Box() + try await box.admit() + try await box.admit() + let deletion = Task { await box.delete() } + + await box.end() + try await Task.sleep(nanoseconds: 50_000_000) + let settledEarly = await box.settled + XCTAssertFalse(settledEarly) + + await box.end() + await deletion.value + let settled = await box.settled + XCTAssertTrue(settled) + } +} diff --git a/changelog.d/next/1235.added.md b/changelog.d/next/1235.added.md new file mode 100644 index 000000000..0a48534fc --- /dev/null +++ b/changelog.d/next/1235.added.md @@ -0,0 +1 @@ +Bitkit now uses your pubky.app profile, so edits in Bitkit and on pubky.app show in both. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 3c83c19a1..b097fc568 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -56,4 +56,6 @@ On iOS, open a saved contact's link while a stored identity's session restoratio `create-profile.xml` records the name-only saving interval and checks the saved name after reopening and relaunching. Use a fresh unfunded identity per run and record the exact app/SDK revisions and network. With controlled network fault injection, block direct UDP while keeping the configured HTTP relays and homeserver reachable; saving should still complete. Reject the required profile write separately and verify saving reports failure instead of navigating to Pay Contacts. Fault injection is a manual check beyond journey-runner capabilities. +`edit-pubky-app-profile.xml` checks that Edit Profile presents the profile as the public pubky.app profile and that a name edit is saved and survives a relaunch. Use an identity whose pubky.app profile you can open on pubky.app; checking the edit there, and an edit made in pubky.app while Bitkit is open, are manual checks beyond journey-runner capabilities. + On iOS, the restart check waits for the full profile (`ProfileViewName`). A matching `ProfileCachedName` during loading does not complete that check. Record an unfinished load separately from a loaded profile with a missing or incorrect name. diff --git a/journeys/profile/edit-pubky-app-profile.xml b/journeys/profile/edit-pubky-app-profile.xml new file mode 100644 index 000000000..ee1913eba --- /dev/null +++ b/journeys/profile/edit-pubky-app-profile.xml @@ -0,0 +1,18 @@ + + + Precondition: a disposable wallet signed in to a Pubky profile, PIN off and an English device. + This checks that Edit Profile presents the profile as the user's public pubky.app profile and + that a name change is saved and shown again after a relaunch. Whether the change also reached + pubky.app is checked outside the app, under the PR's manual tests. + + + Open Profile using "ProfileButton" and wait for "ProfileViewName" + Tap "ProfileEdit" and wait for "ProfileEditName" + Verify the footer note says this is your pubky.app profile and that it is public + Replace the name with "Edited In Bitkit" and dismiss the keyboard + Tap "ProfileEditSave" and wait for "ProfileUpdatedToast" + Verify "ProfileViewName" shows "Edited In Bitkit" + Relaunch the app, open Profile using "ProfileButton" and wait for the full profile name (id "ProfileViewName", predicate exists) + Verify "ProfileViewName" shows "Edited In Bitkit" + + From c278e2a8f632f7774c19acab0f95db1d9601ad36 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 19:38:01 +0100 Subject: [PATCH 03/10] Merge pull request #1252 from synonymdev/chore/bump-ldk-node-vss chore: bump ldk-node and vss client (cherry picked from commit 1ebdfc56f6f3915fca1d0743f3022bdfee96d672) --- Bitkit.xcodeproj/project.pbxproj | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index a20922b38..a329b8bc6 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1208,7 +1208,7 @@ repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; requirement = { kind = exactVersion; - version = "0.5.26-rc1"; + version = "0.5.26-rc2"; }; }; 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */ = { @@ -1224,7 +1224,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.78"; + version = "0.7.0-rc.79"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0542f0515..ab0cd080b 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "1976754dd49886e102f2b3132d5f700d6aabd8d7", - "version" : "0.7.0-rc.78" + "revision" : "faddf6d3a238b6727216c7d32bd8339dfe0d6eab", + "version" : "0.7.0-rc.79" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/vss-rust-client-ffi", "state" : { - "revision" : "eba41ef62b7314aa75827a5a3e71d2b23930b981", - "version" : "0.5.26-rc1" + "revision" : "cf5b5cdfb8aaa79c06953fc57e98d96236c57d25", + "version" : "0.5.26-rc2" } } ], From d9a136e6860fbe9d231db01c328ee0fd3256a4d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 20:15:51 +0100 Subject: [PATCH 04/10] Merge pull request #1290 from synonymdev/chore/bump-ldk-node-rc80 chore: bump ldk-node to 0.7.0-rc.80 (cherry picked from commit 1b56274c100cc24e21560e266455f1cfabd9b2f3) --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- changelog.d/next/1290.security.md | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog.d/next/1290.security.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index a329b8bc6..1d839c800 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1224,7 +1224,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.79"; + version = "0.7.0-rc.80"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ab0cd080b..e2ca3b617 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "faddf6d3a238b6727216c7d32bd8339dfe0d6eab", - "version" : "0.7.0-rc.79" + "revision" : "b6335ecd86e9e9dc4e824dcb4b6065273572ca9d", + "version" : "0.7.0-rc.80" } }, { diff --git a/changelog.d/next/1290.security.md b/changelog.d/next/1290.security.md new file mode 100644 index 000000000..53f44a5b9 --- /dev/null +++ b/changelog.d/next/1290.security.md @@ -0,0 +1 @@ +Updated the Lightning node with upstream security fixes. From a87e048c8f3c947249f4a51d50306662141300c6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 19:54:02 +0100 Subject: [PATCH 05/10] Merge pull request #1292 from synonymdev/chore/paykit-rc8 chore: bump paykit to 0.1.1-rc8 (cherry picked from commit a9c1a9bd873f2ad1cd45c5599ad32464d9e30576) --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 1d839c800..22e60d692 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1200,7 +1200,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.1-rc7"; + version = "0.1.1-rc8"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e2ca3b617..2d266e5d4 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "8f1cd4e9fea8b99043f2437757dd27b0f96efb9e", - "version" : "0.1.1-rc7" + "revision" : "adbe24aca6b326d28312abb7e73e8aa6a0392882", + "version" : "0.1.1-rc8" } }, { From c4bfb15735cd9131150116113f77b60b4684d55b Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 12 Nov 2026 08:15:05 +0100 Subject: [PATCH 06/10] chore: version 3.1.1 --- Bitkit.xcodeproj/project.pbxproj | 24 ++++++++++++------------ CHANGELOG.md | 18 +++++++++++++++++- changelog.d/next/1235.added.md | 1 - changelog.d/next/1237.fixed.md | 1 - changelog.d/next/1290.security.md | 1 - 5 files changed, 29 insertions(+), 16 deletions(-) delete mode 100644 changelog.d/next/1235.added.md delete mode 100644 changelog.d/next/1237.fixed.md delete mode 100644 changelog.d/next/1290.security.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 22e60d692..ae86494c4 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -698,7 +698,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -710,7 +710,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.1.1; PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -731,7 +731,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -743,7 +743,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.1.1; PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -763,7 +763,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; @@ -775,7 +775,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.1.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -796,7 +796,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; @@ -808,7 +808,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.1.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -949,7 +949,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; @@ -976,7 +976,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.1.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -1002,7 +1002,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; @@ -1029,7 +1029,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.1.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, diff --git a/CHANGELOG.md b/CHANGELOG.md index a8329699a..f91504d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.1] - 2026-11-12 + +### Added +- Bitkit now uses your pubky.app profile, so edits in Bitkit and on pubky.app show in both. #1235 + +### Fixed +- Payment requests that cannot fit one Paykit message are now refused before anything is sent or paid, a note that is too long asks to be shortened, Pubky authorization explains a refused signup code or a failed connection, and USDT received before a request was made no longer counts as paying it. #1237 + +### Security +- Updated the Lightning node with upstream security fixes. #1290 + + + + + ## [3.1.0] - 2026-11-02 ### Added @@ -327,7 +342,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix keyboard and UI issues in the calculator widget #513 - Preserve msat precision for LNURL pay, withdraw callbacks and bolt11 #512 -[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v3.1.0...HEAD +[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v3.1.1...HEAD +[3.1.1]: https://github.com/synonymdev/bitkit-ios/compare/v3.1.0...v3.1.1 [3.1.0]: https://github.com/synonymdev/bitkit-ios/compare/v3.0.1...v3.1.0 [3.0.1]: https://github.com/synonymdev/bitkit-ios/compare/v3.0.0..v3.0.1 [3.0.0]: https://github.com/synonymdev/bitkit-ios/compare/v2.5.1..v3.0.0 diff --git a/changelog.d/next/1235.added.md b/changelog.d/next/1235.added.md deleted file mode 100644 index 0a48534fc..000000000 --- a/changelog.d/next/1235.added.md +++ /dev/null @@ -1 +0,0 @@ -Bitkit now uses your pubky.app profile, so edits in Bitkit and on pubky.app show in both. diff --git a/changelog.d/next/1237.fixed.md b/changelog.d/next/1237.fixed.md deleted file mode 100644 index 598544a10..000000000 --- a/changelog.d/next/1237.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Payment requests that cannot fit one Paykit message are now refused before anything is sent or paid, a note that is too long asks to be shortened, Pubky authorization explains a refused signup code or a failed connection, and USDT received before a request was made no longer counts as paying it. diff --git a/changelog.d/next/1290.security.md b/changelog.d/next/1290.security.md deleted file mode 100644 index 53f44a5b9..000000000 --- a/changelog.d/next/1290.security.md +++ /dev/null @@ -1 +0,0 @@ -Updated the Lightning node with upstream security fixes. From 25284fa8a67c7ac5b2e54d53d10e64a971015b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Sena?= Date: Mon, 9 Nov 2026 17:09:59 -0300 Subject: [PATCH 07/10] Merge pull request #1250 from synonymdev/ci/blocktank-regtest-auth ci: pass Blocktank regtest auth to E2E jobs (cherry picked from commit 8adc704274b6c6154f56981a2c499a7b5668d997) --- .github/workflows/e2e-staging.yml | 1 + .github/workflows/e2e_migration.yml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index 2c3fba80b..0bdcce8da 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -126,6 +126,7 @@ jobs: env: BACKEND: regtest + BLOCKTANK_REGTEST_AUTH: ${{ secrets.BLOCKTANK_REGTEST_AUTH }} steps: - name: Resolve E2E tests ref diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 9153cbcc5..783c3471f 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -167,6 +167,8 @@ jobs: prepare-wallets: needs: [e2e-branch, resolve-e2e-ref, migration-plan] uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@main + secrets: + BLOCKTANK_REGTEST_AUTH: ${{ secrets.BLOCKTANK_REGTEST_AUTH }} strategy: fail-fast: false matrix: @@ -191,6 +193,7 @@ jobs: name: ios RN - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest + BLOCKTANK_REGTEST_AUTH: ${{ secrets.BLOCKTANK_REGTEST_AUTH }} MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} steps: @@ -367,6 +370,7 @@ jobs: name: ios native - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest + BLOCKTANK_REGTEST_AUTH: ${{ secrets.BLOCKTANK_REGTEST_AUTH }} MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} steps: From b03f4a61012ae367ef3472df3189119b7f46cb2e Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 12 Nov 2026 09:51:26 +0100 Subject: [PATCH 08/10] test: authenticate iOS staging regtest fixtures --- .github/workflows/integration-tests.yml | 4 +- BitkitTests/AddressTypeIntegrationTests.swift | 18 +++---- BitkitTests/BlocktankRegtestFixture.swift | 52 +++++++++++++++++++ BitkitTests/UtxoSelectionTests.swift | 11 ++-- 4 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 BitkitTests/BlocktankRegtestFixture.swift diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 02a79cc44..0f5f155cf 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -42,8 +42,6 @@ concurrency: jobs: test: - # PR code must not publish dependency caches into the dispatch ref's scope. - cache-mode: read if: github.event.pull_request.draft == false name: Run Integration Tests runs-on: macos-15 @@ -130,6 +128,8 @@ jobs: - name: Run integration tests timeout-minutes: 25 + env: + TEST_RUNNER_BLOCKTANK_REGTEST_AUTH: ${{ github.event_name == 'workflow_dispatch' && github.ref_name == 'release-3.1.1' && inputs.pr == '' && inputs.merge_commit == '' && secrets.BLOCKTANK_REGTEST_AUTH || '' }} run: | xcrun simctl bootstatus "$SIMULATOR_UDID" -b echo "⏱️ Starting integration tests at $(date)" diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index 94775ffe5..0d1d3273c 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -355,8 +355,6 @@ final class AddressTypeIntegrationTests: XCTestCase { func testGetChannelFundableBalanceExcludesLegacy() async throws { try await setupWalletAndNode() - let blocktank = CoreService.shared.blocktank - // Enable legacy monitoring and switch to legacy settings.addressTypesToMonitor = [.nativeSegwit, .legacy] UserDefaults.standard.synchronize() @@ -364,10 +362,10 @@ final class AddressTypeIntegrationTests: XCTestCase { let legacyAddress = try await settings.lightningService.newAddressForType(.legacy) Logger.test("Funding legacy address: \(legacyAddress)", context: "AddressTypeIntegrationTests") - let txId = try await blocktank.regtestDepositFunds(address: legacyAddress, amountSat: 50000) + let txId = try await BlocktankRegtestFixture.depositFunds(address: legacyAddress, amountSat: 50000) XCTAssertFalse(txId.isEmpty) - try await blocktank.regtestMineBlocks(6) + try await BlocktankRegtestFixture.mineBlocks(6) // Verify legacy has balance try await waitForBalance(.legacy, atLeast: 1) @@ -387,8 +385,6 @@ final class AddressTypeIntegrationTests: XCTestCase { func testSetMonitoringDisableWithBalanceFails() async throws { try await setupWalletAndNode() - let blocktank = CoreService.shared.blocktank - // Enable taproot monitoring settings.addressTypesToMonitor = [.nativeSegwit] UserDefaults.standard.synchronize() @@ -397,10 +393,10 @@ final class AddressTypeIntegrationTests: XCTestCase { // Fund the taproot address let taprootAddress = try await settings.lightningService.newAddressForType(.taproot) Logger.test("Funding taproot address: \(taprootAddress)", context: "AddressTypeIntegrationTests") - let txId = try await blocktank.regtestDepositFunds(address: taprootAddress, amountSat: 50000) + let txId = try await BlocktankRegtestFixture.depositFunds(address: taprootAddress, amountSat: 50000) XCTAssertFalse(txId.isEmpty) - try await blocktank.regtestMineBlocks(6) + try await BlocktankRegtestFixture.mineBlocks(6) // Verify taproot has balance try await waitForBalance(.taproot, atLeast: 1) @@ -418,8 +414,6 @@ final class AddressTypeIntegrationTests: XCTestCase { func testPruneEmptyPreservesTypesWithBalance() async throws { try await setupWalletAndNode() - let blocktank = CoreService.shared.blocktank - // Enable taproot monitoring settings.addressTypesToMonitor = [.nativeSegwit] UserDefaults.standard.synchronize() @@ -428,10 +422,10 @@ final class AddressTypeIntegrationTests: XCTestCase { // Fund the taproot address let taprootAddress = try await settings.lightningService.newAddressForType(.taproot) Logger.test("Funding taproot for prune test: \(taprootAddress)", context: "AddressTypeIntegrationTests") - let txId = try await blocktank.regtestDepositFunds(address: taprootAddress, amountSat: 50000) + let txId = try await BlocktankRegtestFixture.depositFunds(address: taprootAddress, amountSat: 50000) XCTAssertFalse(txId.isEmpty) - try await blocktank.regtestMineBlocks(6) + try await BlocktankRegtestFixture.mineBlocks(6) try await waitForBalance(.taproot, atLeast: 1) // Add legacy (will be empty) diff --git a/BitkitTests/BlocktankRegtestFixture.swift b/BitkitTests/BlocktankRegtestFixture.swift new file mode 100644 index 000000000..cbfd0a465 --- /dev/null +++ b/BitkitTests/BlocktankRegtestFixture.swift @@ -0,0 +1,52 @@ +import Foundation + +enum BlocktankRegtestFixture { + enum FixtureError: LocalizedError { + case missingAuth + case invalidResponse + case httpFailure(route: String, status: Int) + + var errorDescription: String? { + switch self { + case .missingAuth: "Set TEST_RUNNER_BLOCKTANK_REGTEST_AUTH to the staging Blocktank credential." + case .invalidResponse: "Blocktank returned an invalid regtest fixture response." + case let .httpFailure(route, status): "Blocktank regtest \(route) returned HTTP \(status)." + } + } + } + + static func depositFunds(address: String, amountSat: UInt64) async throws -> String { + let data = try await post("deposit", body: ["address": address, "amountSat": amountSat]) + guard let txid = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !txid.isEmpty + else { + throw FixtureError.invalidResponse + } + return txid + } + + static func mineBlocks(_ count: UInt32) async throws { + _ = try await post("mine", body: ["count": count]) + } + + private static func post(_ route: String, body: [String: Any]) async throws -> Data { + // This credential is only for the staging fixture API. Wallet behavior uses the app's normal services. + let url = URL(string: "https://api.stag0.blocktank.to/blocktank/api/v2/regtest/chain")! + guard let auth = ProcessInfo.processInfo.environment["BLOCKTANK_REGTEST_AUTH"], !auth.isEmpty else { + throw FixtureError.missingAuth + } + + var request = URLRequest(url: url.appendingPathComponent(route), timeoutInterval: 45) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Basic \(Data(auth.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse else { throw FixtureError.invalidResponse } + guard (200 ..< 300).contains(response.statusCode) else { + throw FixtureError.httpFailure(route: route, status: response.statusCode) + } + return data + } +} diff --git a/BitkitTests/UtxoSelectionTests.swift b/BitkitTests/UtxoSelectionTests.swift index 4e5e0e7d3..63218f5fc 100644 --- a/BitkitTests/UtxoSelectionTests.swift +++ b/BitkitTests/UtxoSelectionTests.swift @@ -5,7 +5,6 @@ import XCTest final class UtxoSelectionTests: XCTestCase { let walletIndex = 0 - let blocktank = CoreService.shared.blocktank let lightning = LightningService.shared private static let pollIntervalNanoseconds: UInt64 = 2_000_000_000 @@ -116,7 +115,7 @@ final class UtxoSelectionTests: XCTestCase { Logger.test( "Depositing \(depositAmount) sats to wallet (transaction \(index + 1)/\(depositAmounts.count))", context: "UtxoSelectionTests" ) - let txId = try await blocktank.regtestDepositFunds(address: depositAddress, amountSat: depositAmount) + let txId = try await BlocktankRegtestFixture.depositFunds(address: depositAddress, amountSat: depositAmount) XCTAssertFalse(txId.isEmpty, "Transaction ID should not be empty") transactionIds.append(txId) totalExpectedAmount += depositAmount @@ -128,7 +127,7 @@ final class UtxoSelectionTests: XCTestCase { // Mine some blocks to confirm all transactions Logger.test("Mining 6 blocks to confirm all transactions", context: "UtxoSelectionTests") - try await blocktank.regtestMineBlocks(6) + try await BlocktankRegtestFixture.mineBlocks(6) Logger.test("Blocks mined successfully", context: "UtxoSelectionTests") let outputs = try await waitForSpendableOutputs("\(depositAmounts.count) confirmed deposits") { @@ -209,7 +208,7 @@ final class UtxoSelectionTests: XCTestCase { // Mine a block to confirm the transaction Logger.test("Mining 1 block to confirm the transaction", context: "UtxoSelectionTests") - try await blocktank.regtestMineBlocks(1) + try await BlocktankRegtestFixture.mineBlocks(1) Logger.test("Block mined successfully", context: "UtxoSelectionTests") // Verify the spent ones are missing @@ -277,7 +276,7 @@ final class UtxoSelectionTests: XCTestCase { "Depositing \(depositAmount) sats to wallet (transaction \(index + 1)/\(depositAmounts.count))", context: "UtxoSelectionTests" ) - let txId = try await blocktank.regtestDepositFunds(address: depositAddress, amountSat: depositAmount) + let txId = try await BlocktankRegtestFixture.depositFunds(address: depositAddress, amountSat: depositAmount) XCTAssertFalse(txId.isEmpty, "Transaction ID should not be empty") totalExpectedAmount += depositAmount Logger.test("Deposit transaction \(index + 1) ID: \(txId), Amount: \(depositAmount) sats", context: "UtxoSelectionTests") @@ -287,7 +286,7 @@ final class UtxoSelectionTests: XCTestCase { // Mine blocks to confirm all transactions Logger.test("Mining 6 blocks to confirm all transactions", context: "UtxoSelectionTests") - try await blocktank.regtestMineBlocks(6) + try await BlocktankRegtestFixture.mineBlocks(6) Logger.test("Blocks mined successfully", context: "UtxoSelectionTests") let allUtxos = try await waitForSpendableOutputs("\(depositAmounts.count) confirmed deposits") { From eb94e76cd144685ef82cd35a6199af8cb523abe5 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 12 Nov 2026 11:21:08 +0100 Subject: [PATCH 09/10] fix: preserve tags when restoring a profile --- Bitkit/Managers/PubkyProfileManager.swift | 5 +++ Bitkit/Views/Profile/CreateProfileView.swift | 1 + BitkitTests/PubkyProfileManagerTests.swift | 44 ++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 064d69f06..1238ecd64 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -485,6 +485,7 @@ class PubkyProfileManager: ObservableObject { tags: [String] = [], existingImageUrl: String? = nil, baseRevision: String? = nil, + baseTagsUnread: Bool = false, avatarImage: UIImage? = nil, loadStoredSecretKey: () async throws -> String? = { await Task.detached { PubkyProfileManager.activeSecretKeyHex() }.value @@ -501,6 +502,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, existingImageUrl: existingImageUrl, baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, avatarImage: avatarImage ) return @@ -536,6 +538,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, existingImageUrl: existingImageUrl, baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, avatarImage: avatarImage ) }, @@ -755,6 +758,7 @@ class PubkyProfileManager: ObservableObject { tags: [String], existingImageUrl: String?, baseRevision: String?, + baseTagsUnread: Bool, avatarImage: UIImage? ) async throws { var avatarUri: String? @@ -771,6 +775,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, expectedIdentity: publicKey ) if tagsNotSavedAt != nil { diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 63ec3a939..0b0167d1a 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -234,6 +234,7 @@ struct CreateProfileView: View { tags: existingProfile?.tags ?? [], existingImageUrl: existingProfile?.imageUrl, baseRevision: existingProfile?.revision, + baseTagsUnread: existingProfile?.tagsUnread ?? false, avatarImage: avatarImage ) navigation.navigate(.payContacts) diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 0d7481a08..e3bf6d7f0 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1313,6 +1313,50 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testCreateIdentityPreservesUnreadTagsFromTheRestoreFormAfterRefresh() async throws { + try await withRestoredProfileDefaults { + UserDefaults.standard.set(true, forKey: "pubky_profile_setup_pending") + var unread = makeProfile(publicKey: ringKeyA, name: "Alice") + unread.revision = "restored-revision" + unread.tagsUnread = true + let stub = RemoteProfileStub(profiles: [ringKeyA: unread]) + let publications = ProfilePublications() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + let restored = try XCTUnwrap(manager.profile) + XCTAssertTrue(restored.tagsUnread) + XCTAssertTrue(restored.tags.isEmpty) + + let refreshed = unread.withTags(["friend", "work"]) + var readable = refreshed + readable.tagsUnread = false + await stub.setProfile(readable, for: ringKeyA) + await manager.loadProfile() + XCTAssertEqual(manager.profile?.tags, ["friend", "work"]) + XCTAssertEqual(manager.profile?.tagsUnread, false) + + try await manager.createIdentity( + name: "Renamed", + bio: restored.bio, + links: restored.links, + tags: restored.tags, + baseRevision: restored.revision, + baseTagsUnread: restored.tagsUnread + ) + + let published = await publications.published + let profile = try XCTUnwrap(published.first) + XCTAssertEqual(published.count, 1) + XCTAssertEqual(profile.baseRevision, "restored-revision") + XCTAssertTrue(profile.baseTagsUnread, "The store must preserve the tags the restore form could not show") + } + } + @MainActor func testSignupDoesNotRestoreProfileStateAfterWalletReset() async throws { snapshotAppDefaultsDomain() From accbdd6f7c68fe16fe503f404b5c7a9d30bf5a63 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 12 Nov 2026 12:47:05 +0100 Subject: [PATCH 10/10] docs: complete the 3.1.1 security notes --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f91504d4e..14bb747db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Updated the Lightning node with upstream security fixes. #1290 - - - - +- Updated the VSS client and Paykit with upstream rustls security fixes. #1252, #1292 ## [3.1.0] - 2026-11-02