Files
damus/damus/Models/Purple/DamusPurple.swift
Daniel D’Aquino ad8d30ded1 Improve mechanism of IAP verification with the server right after purchase
It was observed that sending the receipt to the server right after
performing the StoreKit purchase caused issues due to the receipt not
being completely ready when it got sent, causing rejections by the
server.

This commit, in conjuction with backend changes, implements purchase
verification through transaction IDs directly.

Testing
--------

PASS

Device: iPhone 13 Mini (physical device)
iOS: 17.3.1
Damus: This commit
damus-api: `a878da5598a9344a4d351f9a9da16712ce0615b7`
Setup:
- Local server Setup
- Local testing mode on Damus developer settings
- Clean Sandbox account (Create new sandbox account if clearing purchase history does not work)
- Fresh DB
- App is closed before starting.
- Run app on release target
- MOCK_VERIFY=false on server, with all IAP environment correctly setup
Steps:
1. Make Purple IAP purchase. Make sure that:
    - Server logs indicate `/apple-iap/transaction-id` is being hit and returning HTTP 200. PASS
    - No errors appear on the iOS side. After purchase the welcome sheet should appear, and then the account info. PASS

Part of: https://github.com/damus-io/damus/issues/2036
Changelog-Fixed: Fix in-app purchase issue that would trigger an error on purchase before confirming the account information.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-11 09:29:02 +00:00

507 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// DamusPurple.swift
// damus
//
// Created by Daniel DAquino on 2023-12-08.
//
import Foundation
import StoreKit
class DamusPurple: StoreObserverDelegate {
let settings: UserSettingsStore
let keypair: Keypair
var storekit_manager: StoreKitManager
var checkout_ids_in_progress: Set<String> = []
var onboarding_status: OnboardingStatus
@MainActor
var account_cache: [Pubkey: Account]
@MainActor
var account_uuid_cache: [Pubkey: UUID]
init(settings: UserSettingsStore, keypair: Keypair) {
self.settings = settings
self.keypair = keypair
self.account_cache = [:]
self.account_uuid_cache = [:]
self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data
self.onboarding_status = OnboardingStatus()
Task {
let account: Account? = try await self.fetch_account(pubkey: self.keypair.pubkey)
if account == nil {
self.onboarding_status.account_existed_at_the_start = false
}
else {
self.onboarding_status.account_existed_at_the_start = true
}
}
}
// MARK: Functions
func is_profile_subscribed_to_purple(pubkey: Pubkey) async -> Bool? {
return try? await self.get_maybe_cached_account(pubkey: pubkey)?.active
}
var environment: DamusPurpleEnvironment {
return self.settings.purple_enviroment
}
var enable_purple: Bool {
return true
// TODO: On release, we could just replace this with `true` (or some other feature flag)
//return self.settings.enable_experimental_purple_api
}
// Whether to enable Apple In-app purchase support
var enable_purple_iap_support: Bool {
// TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag)
// return self.settings.enable_experimental_purple_iap_support
return true
}
func account_exists(pubkey: Pubkey) async -> Bool? {
guard let account_data = try? await self.get_account_data(pubkey: pubkey) else { return nil }
if let account_info = try? JSONDecoder().decode(AccountInfo.self, from: account_data) {
return account_info.pubkey == pubkey.hex()
}
return false
}
@MainActor
func get_maybe_cached_account(pubkey: Pubkey) async throws -> Account? {
if let account = self.account_cache[pubkey] {
return account
}
return try await fetch_account(pubkey: pubkey)
}
@MainActor
func fetch_account(pubkey: Pubkey) async throws -> Account? {
guard let data = try await self.get_account_data(pubkey: pubkey) ,
let account = Account.from(json_data: data) else {
return nil
}
self.account_cache[pubkey] = account
return account
}
func get_account_data(pubkey: Pubkey) async throws -> Data? {
let url = environment.api_base_url().appendingPathComponent("accounts/\(pubkey.hex())")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return data
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
func make_iap_purchase(product: Product) async throws {
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let result = try await self.storekit_manager.purchase(product: product, id: account_uuid)
switch result {
case .success(.verified(let tx)):
// Record the purchase with the storekit manager, to make sure we have the update on the UIs as soon as possible.
// During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
await tx.finish()
// Send the transaction id to the server
try await self.send_transaction_id(transaction_id: tx.originalID)
default:
// Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
throw PurpleError.iap_purchase_error(result: result)
}
}
@MainActor
func get_maybe_cached_uuid_for_account() async throws -> UUID {
if let account_uuid = self.account_uuid_cache[self.keypair.pubkey] {
return account_uuid
}
return try await fetch_uuid_for_account()
}
@MainActor
func fetch_uuid_for_account() async throws -> UUID {
let url = self.environment.api_base_url().appending(path: "/accounts/\(self.keypair.pubkey)/account-uuid")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Got user UUID from Damus Purple server", for: .damus_purple)
default:
Log.error("Error in getting user UUID with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
let account_uuid_info = try JSONDecoder().decode(AccountUUIDInfo.self, from: data)
self.account_uuid_cache[self.keypair.pubkey] = account_uuid_info.account_uuid
return account_uuid_info.account_uuid
}
func send_receipt() async throws {
// Get the receipt if it's available.
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
let receipt_base64_string = receiptData.base64EncodedString()
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt")
Log.info("Sending in-app purchase receipt to Damus Purple server: %s", for: .damus_purple, receipt_base64_string)
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
default:
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
}
}
}
}
func send_transaction_id(transaction_id: UInt64) async throws {
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let json_text: [String: Any] = ["transaction_id": transaction_id, "account_uuid": account_uuid.uuidString]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/transaction-id")
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent transaction ID to Damus Purple server and activated successfully", for: .damus_purple)
default:
Log.error("Error in sending or verifying transaction ID with Damus Purple server. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
}
}
}
func translate(text: String, source source_language: String, target target_language: String) async throws -> String {
var url = environment.api_base_url()
url.append(path: "/translate")
url.append(queryItems: [
.init(name: "source", value: source_language),
.init(name: "target", value: target_language),
.init(name: "q", value: text)
])
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(TranslationResult.self, from: data).text
default:
Log.error("Translation error with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw PurpleError.translation_error(status_code: httpResponse.statusCode, response: data)
}
}
else {
throw PurpleError.translation_no_response
}
}
func verify_npub_for_checkout(checkout_id: String) async throws {
var url = environment.api_base_url()
url.append(path: "/ln-checkout/\(checkout_id)/verify")
let (data, response) = try await make_nip98_authenticated_request(
method: .put,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Verified npub for checkout id `%s` with Damus Purple server", for: .damus_purple, checkout_id)
default:
Log.error("Error in verifying npub with Damus Purple. HTTP status code: %d; Response: %s; Checkout id: ", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown", checkout_id)
throw PurpleError.checkout_npub_verification_error
}
}
}
@MainActor
func fetch_ln_checkout_object(checkout_id: String) async throws -> LNCheckoutInfo? {
let url = environment.api_base_url().appendingPathComponent("ln-checkout/\(checkout_id)")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
@MainActor
func new_ln_checkout(product_template_name: String) async throws -> LNCheckoutInfo? {
let url = environment.api_base_url().appendingPathComponent("ln-checkout")
let json_text: [String: String] = ["product_template_name": product_template_name]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
@MainActor
func generate_verified_ln_checkout_link(product_template_name: String) async throws -> URL {
let checkout = try await self.new_ln_checkout(product_template_name: product_template_name)
guard let checkout_id = checkout?.id.uuidString.lowercased() else { throw PurpleError.error_processing_response }
try await self.verify_npub_for_checkout(checkout_id: checkout_id)
return self.environment.purple_landing_page_url()
.appendingPathComponent("checkout")
.appending(queryItems: [URLQueryItem(name: "id", value: checkout_id)])
}
@MainActor
/// This function checks the status of all checkout objects in progress with the server, and it does two things:
/// - It returns the ones that were freshly completed
/// - It internally marks them as "completed"
/// Important note: If you call this function, you must use the result, as those checkouts will not be returned the next time you call this function
///
/// - Returns: An array of checkout objects that have been successfully completed.
func check_status_of_checkouts_in_progress() async throws -> [String] {
var freshly_completed_checkouts: [String] = []
for checkout_id in self.checkout_ids_in_progress {
let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
if checkout_info?.is_all_good() == true {
freshly_completed_checkouts.append(checkout_id)
}
if checkout_info?.completed == true {
self.checkout_ids_in_progress.remove(checkout_id)
}
}
return freshly_completed_checkouts
}
@MainActor
/// This function checks the status of a specific checkout id with the server
/// You should use this result immediately, since it will internally be marked as handled
///
/// - Returns: true if this checkout is all good to go. false if not. nil if checkout was not found.
func check_and_mark_ln_checkout_is_good_to_go(checkout_id: String) async throws -> Bool? {
let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
if checkout_info?.completed == true {
self.checkout_ids_in_progress.remove(checkout_id) // Remove if from the list of checkouts in progress
}
return checkout_info?.is_all_good()
}
struct Account {
let pubkey: Pubkey
let created_at: Date
let expiry: Date
let subscriber_number: Int
let active: Bool
func ordinal() -> String? {
let number = Int(self.subscriber_number)
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter.string(from: NSNumber(integerLiteral: number))
}
static func from(json_data: Data) -> Self? {
guard let payload = try? JSONDecoder().decode(Payload.self, from: json_data) else { return nil }
return Self.from(payload: payload)
}
static func from(payload: Payload) -> Self? {
guard let pubkey = Pubkey(hex: payload.pubkey) else { return nil }
return Self(
pubkey: pubkey,
created_at: Date.init(timeIntervalSince1970: TimeInterval(payload.created_at)),
expiry: Date.init(timeIntervalSince1970: TimeInterval(payload.expiry)),
subscriber_number: Int(payload.subscriber_number),
active: payload.active
)
}
struct Payload: Codable {
let pubkey: String // Hex-encoded string
let created_at: UInt64 // Unix timestamp
let expiry: UInt64 // Unix timestamp
let subscriber_number: UInt
let active: Bool
}
}
}
// MARK: API types
extension DamusPurple {
fileprivate struct AccountInfo: Codable {
let pubkey: String
let created_at: UInt64
let expiry: UInt64?
let active: Bool
}
struct LNCheckoutInfo: Codable {
// Note: Swift will decode a JSON full of extra fields into a Struct with only a subset of them, but not the other way around
// Therefore, to avoid compatibility concerns and complexity, we should only use the fields we need
// The ones we do not need yet will be left commented out until we need them.
let id: UUID
/*
let product_template_name: String
let verified_pubkey: String?
*/
let invoice: Invoice?
let completed: Bool
struct Invoice: Codable {
/*
let bolt11: String
let label: String
let connection_params: ConnectionParams
*/
let paid: Bool?
/*
struct ConnectionParams: Codable {
let nodeid: String
let address: String
let rune: String
}
*/
}
/// Indicates whether this checkout is all good to go.
/// The checkout is good to go if it is marked as complete and the invoice has been successfully paid
/// - Returns: true if this checkout is all good to go. false otherwise
func is_all_good() -> Bool {
return self.completed == true && self.invoice?.paid == true
}
}
fileprivate struct AccountUUIDInfo: Codable {
let account_uuid: UUID
}
}
// MARK: Helper structures
extension DamusPurple {
enum PurpleError: Error {
case translation_error(status_code: Int, response: Data)
case http_response_error(status_code: Int, response: Data)
case error_processing_response
case iap_purchase_error(result: Product.PurchaseResult)
case iap_receipt_verification_error(status: Int, response: Data)
case translation_no_response
case checkout_npub_verification_error
}
struct TranslationResult: Codable {
let text: String
}
struct OnboardingStatus {
var account_existed_at_the_start: Bool? = nil
var onboarding_was_shown: Bool = false
init() {
}
init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) {
self.account_existed_at_the_start = account_active_at_the_start
self.onboarding_was_shown = onboarding_was_shown
}
func user_has_never_seen_the_onboarding_before() -> Bool {
return onboarding_was_shown == false && account_existed_at_the_start == false
}
}
}