Repository: damus-io/damus
Pull request #2982: Add one-click Coinos wallet setup
Author: login: danieldaquino
Created: 2025-04-17T03:46:28Z | Merged: 2025-04-21T19:23:31Z

## Summary

This commit implements a one-click Coinos wallet setup.

This was implemented using the Coinos API, and using account details that are deterministically generated from the user's private key.

## Checklist

- [x] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
- [x] I have tested the changes in this PR
- [x] I have opened or referred to an existing github issue related to this change.
- [x] My PR is either small, or I have split it into smaller logical commits that are easier to review
- [x] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin)
- [x] I have added appropriate changelog entries for the changes in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc)
    - [ ] I do not need to add a changelog entry. Reason: _[Please provide a reason]_
- [x] I have added appropriate `Closes:` or `Fixes:` tags in the commit messages wherever applicable, or made sure those are not needed. See [Submitting patches](https://github.com/damus-io/damus/blob/master/docs/CONTRIBUTING.md#submitting-patches)

## Test report

**Device:** iPhone SE simulator

**iOS:** 18.2

**Damus:** ac1ae3a15cee6f0f2e3edffb5012497f48ac5388

**Steps:**
1. Create new deterministic wallet using one-click setup, ensure it works and immediately takes user to the balance view.
2. Receive a zap from another account, ensure the zap is received on the new wallet, and that the balance view reflects that.
3. Send a zap to another account, ensure the zap is sent and the balance view reflects that after refreshing.
4. Disconnect wallet and connect Coinos wallet manually via the website. Ensure wallets can still be connected manually.
5. Disconnect that manual wallet and try the one-click setup again. Ensure it loads the user's _existing_ deterministic wallet (previous balance and transactions are there).
6. Login with the user's npub. Ensure the "one-click setup" option is not available, with a human-readable message.
7. Login with the user's nsec again, and connect the deterministic wallet
8. Restart app. Ensure the deterministic wallet is still there, connected.
9. Disconnect wallet again.
10. Disconnect Wi-Fi and try the one-click setup. Ensure that — after a few seconds — a human friendly error screen appears.

**Results:**
- [x] PASS
- [ ] Partial PASS
  - Details: _[Please provide details of the partial pass]_

## Screenshots

![Simulator Screenshot - iPhone SE (3rd generation) - 2025-04-16 at 20 28 50](https://github.com/user-attachments/assets/283b35f5-6ecd-4511-8e03-a1e90ff1ca2e)
![Simulator Screenshot - iPhone SE (3rd generation) - 2025-04-16 at 20 28 04](https://github.com/user-attachments/assets/2b3f633a-aa1a-418e-aa12-8042cffdce0a)
![Simulator Screenshot - iPhone SE (3rd generation) - 2025-04-16 at 20 42 35](https://github.com/user-attachments/assets/e4251b6e-8450-4c04-a60a-0d1760af116d)




Diff:
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
index 6f452ea854..0d75153997 100644
--- a/damus.xcodeproj/project.pbxproj
+++ b/damus.xcodeproj/project.pbxproj
@@ -1649,6 +1649,9 @@
 		D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
 		D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B93D2A9AD44700DC3548 /* Message.swift */; };
 		D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */; };
+		D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
+		D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
+		D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
 		D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
 		D7D68FF92C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
 		D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
@@ -2554,6 +2557,7 @@
 		D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
 		D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = "<group>"; };
 		D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = "<group>"; };
+		D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosDeterministicAccountClient.swift; sourceTree = "<group>"; };
 		D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationClient.swift; sourceTree = "<group>"; };
 		D7D68FF82C9E01B60015A515 /* KFClickable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFClickable.swift; sourceTree = "<group>"; };
 		D7DB1FDD2D5A78CE00CF06DA /* NIP44.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP44.swift; sourceTree = "<group>"; };
@@ -3363,6 +3367,7 @@
 				D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */,
 				D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */,
 				D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */,
+				D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */,
 			);
 			path = Util;
 			sourceTree = "<group>";
@@ -4859,6 +4864,7 @@
 				4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */,
 				4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */,
 				4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */,
+				D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
 				D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */,
 				4C363A962827096D006E126D /* PostBlock.swift in Sources */,
 				4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */,
@@ -5247,6 +5253,7 @@
 				82D6FB652CD99F7900C925F4 /* CollectionExtension.swift in Sources */,
 				82D6FB662CD99F7900C925F4 /* ZapDataModel.swift in Sources */,
 				82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
+				D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
 				82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
 				82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
 				82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
@@ -5996,6 +6003,7 @@
 				D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
 				D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
 				D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
+				D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
 				D703D7802C670C2500A400EA /* NIP05.swift in Sources */,
 				D703D7AA2C670E5D00A400EA /* verifier.c in Sources */,
 				D73E5E1D2C6A9680007EB227 /* PreviewCache.swift in Sources */,
diff --git a/damus/Util/CoinosDeterministicAccountClient.swift b/damus/Util/CoinosDeterministicAccountClient.swift
new file mode 100644
index 0000000000..2d99334ed3
--- /dev/null
+++ b/damus/Util/CoinosDeterministicAccountClient.swift
@@ -0,0 +1,340 @@
+//
+//  CoinosDeterministicClient.swift
+//  damus
+//
+//  Created by Daniel D’Aquino on 2025-04-14.
+//
+
+import Foundation
+
+/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
+///
+/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
+class CoinosDeterministicAccountClient {
+    // MARK: - State
+    
+    /// The user's normal keypair for using Nostr
+    private let userKeypair: FullKeypair
+    /// The JWT authentication token with Coinos
+    private var jwtAuthToken: String? = nil
+    
+    
+    // MARK: - Computed properties for a deterministic wallet
+    
+    /// A deterministic keypair for the NWC connection derived from the user's private key
+    private var nwcKeypair: FullKeypair? {
+        let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id))   // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
+        return FullKeypair(privkey: nwcPrivateKey)
+    }
+    
+    /// A deterministic username for a Coinos account
+    private var username: String? {
+        // Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
+        // Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
+        guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
+        // There is very little risk of a birthday attack on getting only the first 16 characters, because:
+        // 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
+        // 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
+        //
+        // In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
+        // According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
+        // even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
+        return String(fullText.prefix(16))
+    }
+    
+    /// A deterministic password for a Coinos account
+    private var password: String? {
+        // Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
+        return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
+    }
+    
+    /// A deterministic NWC app connection name
+    private var nwcConnectionName: String { return "Damus" }
+    
+    
+    // MARK: - Initialization
+    
+    /// Initializes the client with the user's keypair
+    init(userKeypair: FullKeypair) {
+        self.userKeypair = userKeypair
+    }
+    
+    
+    // MARK: - Authentication and registration
+    
+    /// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
+    func loginOrRegister() async throws {
+        do {
+            // Check if client has an account
+            try await self.login()
+        }
+        catch {
+            guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
+            // Client does not seem to have an account, create one
+            try await self.register()
+            try await self.login()
+        }
+    }
+    
+    /// Registers for a Coinos account using deterministic account details.
+    ///
+    /// It succeeds if it returns without throwing errors.
+    func register() async throws {
+        guard let username, let password else { throw ClientError.errorFormingRequest }
+        let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
+        let jsonData = try JSONEncoder().encode(registerPayload)
+        
+        let url = URL(string: "https://coinos.io/api/register")!
+        let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
+        
+        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
+            return
+        } else {
+            throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
+        }
+    }
+    
+    /// Logs into the deterministic account, if an auth token is not present
+    func loginIfNeeded() async throws {
+        if self.jwtAuthToken == nil { try await self.login() }
+    }
+    
+    /// Logs into to our deterministic account.
+    ///
+    /// Succeeds if it returns without returning errors.
+    ///
+    /// Mutating function, will update the client's internal state.
+    func login() async throws {
+        self.jwtAuthToken = try await sendLoginRequest().token
+    }
+    
+    /// Sends the login request and return the response
+    ///
+    /// Does NOT update the internal login state.
+    private func sendLoginRequest() async throws -> AuthResponse {
+        guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
+        guard let username, let password else { throw ClientError.errorFormingRequest }
+        let credentials = UserCredentials(username: username, password: password)
+        let jsonData = try JSONEncoder().encode(credentials)
+        
+        let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
+        
+        if let httpResponse = response as? HTTPURLResponse {
+            switch httpResponse.statusCode {
+            case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
+            case 401: throw ClientError.unauthorized
+            default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
+            }
+        }
+        throw ClientError.errorProcessingResponse
+    }
+    
+    
+    // MARK: - Managing NWC connections
+    
+    /// Creates a new NWC connection
+    ///
+    /// Note: Account must exist before calling this endpoint
+    func createNWCConnection() async throws -> WalletConnectURL {
+        guard let nwcKeypair else { throw ClientError.errorFormingRequest }
+        guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
+        
+        try await self.loginIfNeeded()
+        
+        let config = try defaultWalletConnectionConfig()
+        let configData = try encode_json_data(config)
+        
+        let (data, response) = try await self.makeAuthenticatedRequest(
+            method: .post,
+            url: urlEndpoint,
+            payload: configData,
+            payload_type: .json
+        )
+        
+        if let httpResponse = response as? HTTPURLResponse {
+            switch httpResponse.statusCode {
+            case 200:
+                guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
+                return nwc
+            case 401: throw ClientError.unauthorized
+            default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
+            }
+        }
+        throw ClientError.errorProcessingResponse
+    }
+    
+    /// Returns the default wallet connection config
+    private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
+        guard let nwcKeypair else { throw ClientError.errorFormingRequest }
+        return NewWalletConnectionConfig(
+            name: self.nwcConnectionName,
+            secret: nwcKeypair.privkey.hex(),
+            pubkey: nwcKeypair.pubkey.hex(),
+            max_amount: 30000,  // 30K sats per week maximum
+            budget_renewal: .weekly
+        )
+    }
+    
+    /// Gets the NWC URL for the deterministic NWC app connection
+    ///
+    /// Account must already exist before calling this
+    ///
+    /// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
+    func getNWCUrl() async throws -> WalletConnectURL? {
+        guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
+        return WalletConnectURL(str: nwc)
+    }
+    
+    /// Gets the deterministic NWC app connection configuration details, if it exists
+    ///
+    /// Account must already exist before calling this
+    ///
+    /// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
+    func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
+        guard let nwcKeypair else { throw ClientError.errorFormingRequest }
+        guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
+        
+        try await self.loginIfNeeded()
+        
+        let (data, response) = try await self.makeAuthenticatedRequest(
+            method: .get,
+            url: url,
+            payload: nil,
+            payload_type: nil
+        )
+        
+        if let httpResponse = response as? HTTPURLResponse {
+            switch httpResponse.statusCode {
+            case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
+            case 401: throw ClientError.unauthorized
+            case 404: return nil
+            default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
+            }
+        }
+        throw ClientError.errorProcessingResponse
+    }
+    
+    
+    // MARK: - Lower level request convenience functions
+    
+    /// Makes a request without any authorization
+    func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
+        var request = URLRequest(url: url)
+        request.httpMethod = method.rawValue
+        request.httpBody = payload
+
+        if let payload_type {
+            request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
+        }
+        return try await URLSession.shared.data(for: request)
+    }
+    
+    /// Makes an authenticated request with our JWT auth token.
+    ///
+    /// Client must be logged-in before calling this, otherwise an error will be thrown.
+    func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
+        guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
+        
+        var request = URLRequest(url: url)
+        request.httpMethod = method.rawValue
+        request.httpBody = payload
+
+        request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
+        if let payload_type {
+            request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
+        }
+        return try await URLSession.shared.data(for: request)
+    }
+    
+    
+    // MARK: - Helper structures
+    
+    /// Payload for registering for a new Coinos account
+    struct RegisterRequest: Codable {
+        /// New user credentials
+        let user: UserCredentials
+    }
+    
+    /// Payload for user credentials (sign-up and login)
+    struct UserCredentials: Codable {
+        /// The username
+        let username: String
+        /// The user password
+        let password: String
+    }
+    
+    /// A successful response to a login auth endpoint
+    struct AuthResponse: Codable {
+        /// The JWT token to be applied to any authenticated API calls
+        let token: String
+    }
+    
+    /// Used by the client to define new NWC configurations
+    struct NewWalletConnectionConfig: Codable {
+        /// The name of the connection
+        let name: String
+        /// 32 Hex-encoded bytes containing a shared private key secret
+        let secret: String
+        /// 32 Hex-encoded bytes containing the pubkey for the secret
+        let pubkey: String
+        /// Max amount that can be spent in each renewal period (measured in sats)
+        let max_amount: UInt64
+        /// The period of time it takes for the budget limits to reset
+        let budget_renewal: BudgetRenewalPeriod
+    }
+    
+    /// The NWC connection configuration details
+    ///
+    /// ## Implementation notes
+    ///
+    /// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
+    struct WalletConnectionConfig: Codable {
+        /// The name of the connection
+        let name: String?
+        /// 32 Hex-encoded bytes containing a shared private key secret
+        let secret: String?
+        /// 32 Hex-encoded bytes containing the pubkey for the secret
+        let pubkey: String?
+        /// Max amount that can be spent in every renewal period (measured in sats)
+        let max_amount: UInt64?
+        /// The NWC url generated by the server
+        let nwc: String?
+        /// Budget renewal information
+        let budget_renewal: BudgetRenewalPeriod?
+    }
+    
+    /// A period of time it takes for budget limits to be reset
+    enum BudgetRenewalPeriod: String, Codable {
+        /// Resets once a week
+        case weekly
+    }
+    
+    /// A client error occured
+    enum ClientError: Error, Equatable {
+        /// Received an unexpected HTTP response
+        ///
+        /// Could be for a variety of reasons.
+        case unexpectedHTTPResponse(status_code: Int, response: Data)
+        /// Error forming the request, generally due to missing or inconsistent internal data
+        ///
+        /// Probably caused by a programming error.
+        case errorFormingRequest
+        /// The client could not process the response from the server
+        ///
+        /// Might be a sign of an incompatibility bug
+        case errorProcessingResponse
+        /// The action performed is not authorized
+        /// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
+        case unauthorized
+        /// Client not logged in on a call that expected login
+        case notLoggedIn
+    }
+}
+
+/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
+///
+/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
+fileprivate func sha256Hex(text: String) -> String? {
+    guard let data = text.data(using: .utf8) else { return nil }
+    return sha256(data).toHexString()
+}
diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift
index 01beeb3d37..28b9be4818 100644
--- a/damus/Views/Wallet/ConnectWalletView.swift
+++ b/damus/Views/Wallet/ConnectWalletView.swift
@@ -16,7 +16,9 @@ struct ConnectWalletView: View {
     @State var error: String? = nil
     @State var wallet_scan_result: WalletScanResult = .scanning
     @State var show_introduction: Bool = true
+    @State var show_coinos_options: Bool = false
     var nav: NavigationCoordinator
+    let userKeypair: Keypair
     
     var body: some View {
         MainContent
@@ -146,9 +148,14 @@ struct ConnectWalletView: View {
             
             Spacer()
             
-            CoinosButton() {
-                show_introduction = false
-                openURL(URL(string:"https://coinos.io/settings/nostr")!)
+            VStack(spacing: 5) {
+                CoinosButton() {
+                    self.show_coinos_options = true
+                }
+                Text("Coinos is a service operated by a third-party. We have no access to your Coinos wallet.", comment: "Small caption with a disclaimer that Damus does not own or have access to Coinos wallets, Coinos is a third-party service.")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+                    .multilineTextAlignment(.center)
             }
             .padding()
         }
@@ -161,6 +168,110 @@ struct ConnectWalletView: View {
                 .padding(2) // Avoids border clipping on the sides
         )
         .padding(.top, 20)
+        .sheet(isPresented: $show_coinos_options, content: {
+            CoinosConnectionOptionsSheet
+        })
+    }
+    
+    var CoinosConnectionOptionsSheet: some View {
+        VStack(spacing: 20) {
+            Text("How would you like to connect to your Coinos wallet?", comment: "Question for the user when connecting a Coinos wallet.")
+                .font(.title3)
+                .bold()
+                .multilineTextAlignment(.center)
+                .padding(.bottom, 10)
+                .lineLimit(2)
+            
+            Spacer()
+            
+            VStack(spacing: 5) {
+                Button(
+                    action: { self.oneClickSetup() },
+                    label: {
+                        HStack {
+                            Spacer()
+                            VStack {
+                                HStack {
+                                    Image(systemName: "wand.and.sparkles")
+                                    Text("One-click setup", comment: "Button label for users to do a one-click Coinos wallet setup.")
+                                }
+                                // I have to hide this on npub logins, because otherwise SwiftUI will start truncating text
+                                if self.userKeypair.privkey != nil {
+                                    Text("Also click here if you had a one-click setup before.", comment: "Button description hint for users who may want to do a one-click setup.")
+                                        .font(.caption)
+                                }
+                            }
+                            Spacer()
+                        }
+                    }
+                )
+                .frame(maxWidth: .infinity)
+                .buttonStyle(GradientButtonStyle())
+                .opacity(self.userKeypair.privkey == nil ? 0.5 : 1.0)
+                .disabled(self.userKeypair.privkey == nil)
+                
+                if self.userKeypair.privkey == nil {
+                    Text("You must be logged in with your nsec to use this option.", comment: "Warning text for users who cannot create a Coinos account via the one-click setup without being logged in with their nsec.")
+                        .font(.caption)
+                        .multilineTextAlignment(.center)
+                        .foregroundStyle(.secondary)
+                    
+                    Text("Your profile will not be shared with Coinos.", comment: "Label text for users to reassure them that their nsec is not shared with a third party.")
+                        .font(.caption)
+                        .multilineTextAlignment(.center)
+                        .foregroundStyle(.secondary)
+                }
+            }
+            
+            Button(
+                action: {
+                    show_introduction = false
+                    show_coinos_options = false
+                    openURL(URL(string:"https://coinos.io/settings/nostr")!)
+                },
+                label: {
+                    HStack {
+                        Spacer()
+                        
+                        VStack {
+                            HStack {
+                                Image(systemName: "arrow.up.right")
+                                Text("Connect via the website", comment: "Button label for users who are setting up a Coinos wallet and would like to connect via the website")
+                            }
+                            Text("Click here if you have a Coinos username and password.", comment: "Button description hint for users who may want to connect via the website.")
+                                .font(.caption)
+                        }
+                        
+                        Spacer()
+                    }
+                }
+            )
+            .frame(maxWidth: .infinity)
+        }
+        .padding()
+        .presentationDetents([.height(300)])
+    }
+    
+    func oneClickSetup() {
+        Task {
+            show_coinos_options = false
+            do {
+                guard let fullKeypair = self.userKeypair.to_full() else {
+                    throw CoinosDeterministicAccountClient.ClientError.errorFormingRequest
+                }
+                let client = CoinosDeterministicAccountClient(userKeypair: fullKeypair)
+                try await client.loginOrRegister()
+                let nwcURL = try await client.createNWCConnection()
+                model.connect(nwcURL)   // Connect directly, to make it a true one-click setup
+            }
+            catch {
+                present_sheet(.error(.init(
+                    user_visible_description: NSLocalizedString("Something went wrong when performing the one-click Coinos wallet setup.", comment: "Error label when user tries the one-click Coinos wallet setup but fails for some generic reason."),
+                    tip: NSLocalizedString("Check your internet connection and try again. If the error persists, contact support.", comment: "Error tip when user tries to create the one-click Coinos wallet setup but fails for a generic reason."),
+                    technical_info: error.localizedDescription
+                )))
+            }
+        }
     }
     
     var ManualSetup: some View {
@@ -270,7 +381,7 @@ struct ConnectWalletView: View {
 
 struct ConnectWalletView_Previews: PreviewProvider {
     static var previews: some View {
-        ConnectWalletView(model: WalletModel(settings: UserSettingsStore()), nav: .init())
+        ConnectWalletView(model: WalletModel(settings: UserSettingsStore()), nav: .init(), userKeypair: test_keypair)
             .previewDisplayName("Main Wallet Connect View")
         ConnectWalletView.AreYouSure(nwc: get_test_nwc(), show_introduction: .constant(false), model: WalletModel(settings: test_damus_state.settings))
             .previewDisplayName("Are you sure screen")
diff --git a/damus/Views/Wallet/NWCSettings.swift b/damus/Views/Wallet/NWCSettings.swift
index c4e801b30d..ae2bb94dd2 100644
--- a/damus/Views/Wallet/NWCSettings.swift
+++ b/damus/Views/Wallet/NWCSettings.swift
@@ -14,6 +14,8 @@ struct NWCSettings: View {
     @ObservedObject var model: WalletModel
     @ObservedObject var settings: UserSettingsStore
     
+    @Environment(\.dismiss) var dismiss
+    
     
     func donation_binding() -> Binding<Double> {
         return Binding(get: {
@@ -136,6 +138,7 @@ struct NWCSettings: View {
             
             Button(action: {
                 self.model.disconnect()
+                dismiss()
             }) {
                 HStack {
                     Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.")
diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift
index 6d78e34273..2dae37419f 100644
--- a/damus/Views/Wallet/WalletView.swift
+++ b/damus/Views/Wallet/WalletView.swift
@@ -39,9 +39,9 @@ struct WalletView: View {
     var body: some View {
         switch model.connect_state {
         case .new:
-            ConnectWalletView(model: model, nav: damus_state.nav)
+            ConnectWalletView(model: model, nav: damus_state.nav, userKeypair: self.damus_state.keypair)
         case .none:
-            ConnectWalletView(model: model, nav: damus_state.nav)
+            ConnectWalletView(model: model, nav: damus_state.nav, userKeypair: self.damus_state.keypair)
         case .existing(let nwc):
             MainWalletView(nwc: nwc)
                 .toolbar {


Reviews:
user: login: jb55
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T04:06:25Z
user: login: jb55
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T04:09:20Z
user: login: jb55
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T04:12:03Z
user: login: danieldaquino
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T18:47:08Z

Review comments:
diff_hunk: @@ -0,0 +1,340 @@
+//
+//  CoinosDeterministicClient.swift
+//  damus
+//
+//  Created by Daniel D’Aquino on 2025-04-14.
+//
+
+import Foundation
+
+/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
+///
+/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
+class CoinosDeterministicAccountClient {
+    // MARK: - State
+    
+    /// The user's normal keypair for using Nostr
+    private let userKeypair: FullKeypair
+    /// The JWT authentication token with Coinos
+    private var jwtAuthToken: String? = nil
+    
+    
+    // MARK: - Computed properties for a deterministic wallet
+    
+    /// A deterministic keypair for the NWC connection derived from the user's private key
+    private var nwcKeypair: FullKeypair? {
+        let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id))   // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
+        return FullKeypair(privkey: nwcPrivateKey)
+    }
+    
+    /// A deterministic username for a Coinos account
+    private var username: String? {
+        // Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
+        // Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
+        guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
+        // There is very little risk of a birthday attack on getting only the first 16 characters, because:
+        // 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
+        // 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
+        //
+        // In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
+        // According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
+        // even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
+        return String(fullText.prefix(16))
path: damus/Util/CoinosDeterministicAccountClient.swift
user: login: jb55
body: does coinos have a 16 character limit on username or something?
created_at: 2025-04-17T04:06:24Z
author_association: COLLABORATOR
diff_hunk: @@ -161,6 +162,95 @@ struct ConnectWalletView: View {
                 .padding(2) // Avoids border clipping on the sides
         )
         .padding(.top, 20)
+        .sheet(isPresented: $show_coinos_options, content: {
+            CoinosConnectionOptionsSheet
+        })
+    }
+    
+    var CoinosConnectionOptionsSheet: some View {
+        VStack {
+            Text("How would you like to connect to your Coinos wallet?", comment: "Question for the user when connecting a Coinos wallet.")
+                .font(.title3)
+                .bold()
+                .multilineTextAlignment(.center)
+                .padding(.bottom, 10)
+            
+            Spacer()
+            
+            Button(
+                action: { self.oneClickSetup() },
+                label: {
+                    HStack {
+                        Spacer()
+                        VStack {
+                            HStack {
+                                Image(systemName: "wand.and.sparkles")
+                                Text("One-click setup", comment: "Button label for users to do a one-click Coinos wallet setup.")
+                            }
+                            Text("Also click here if you had a one-click setup before.", comment: "Button description hint for users who may want to do a one-click setup.")
+                                .font(.caption)
+                        }
+                        Spacer()
+                    }
+                }
+            )
+            .frame(maxWidth: .infinity)
+            .buttonStyle(GradientButtonStyle())
+            .opacity(self.userKeypair.privkey == nil ? 0.5 : 1.0)
+            .disabled(self.userKeypair.privkey == nil)
+            
+            if self.userKeypair.privkey == nil {
+                Text("The one-click setup requires the user's private key. Please login with your nsec to use this option.", comment: "Warning text for users who cannot create a Coinos account via the one-click setup without being logged in with their nsec.")
path: damus/Views/Wallet/ConnectWalletView.swift
user: login: jb55
body: this message sounds a bit scary, it almost sounds like we are sending the users private key to coinos or something. maybe we should make it clear that the users profile is not linked to the coinos account, and there is no identifiable information
created_at: 2025-04-17T04:09:20Z
author_association: COLLABORATOR
diff_hunk: @@ -161,6 +162,95 @@ struct ConnectWalletView: View {
                 .padding(2) // Avoids border clipping on the sides
         )
         .padding(.top, 20)
+        .sheet(isPresented: $show_coinos_options, content: {
+            CoinosConnectionOptionsSheet
+        })
+    }
+    
+    var CoinosConnectionOptionsSheet: some View {
+        VStack {
+            Text("How would you like to connect to your Coinos wallet?", comment: "Question for the user when connecting a Coinos wallet.")
+                .font(.title3)
+                .bold()
+                .multilineTextAlignment(.center)
+                .padding(.bottom, 10)
+            
+            Spacer()
+            
+            Button(
+                action: { self.oneClickSetup() },
+                label: {
+                    HStack {
+                        Spacer()
+                        VStack {
+                            HStack {
+                                Image(systemName: "wand.and.sparkles")
+                                Text("One-click setup", comment: "Button label for users to do a one-click Coinos wallet setup.")
+                            }
+                            Text("Also click here if you had a one-click setup before.", comment: "Button description hint for users who may want to do a one-click setup.")
+                                .font(.caption)
+                        }
+                        Spacer()
+                    }
+                }
+            )
+            .frame(maxWidth: .infinity)
+            .buttonStyle(GradientButtonStyle())
+            .opacity(self.userKeypair.privkey == nil ? 0.5 : 1.0)
+            .disabled(self.userKeypair.privkey == nil)
+            
+            if self.userKeypair.privkey == nil {
+                Text("The one-click setup requires the user's private key. Please login with your nsec to use this option.", comment: "Warning text for users who cannot create a Coinos account via the one-click setup without being logged in with their nsec.")
path: damus/Views/Wallet/ConnectWalletView.swift
user: login: jb55
body: maybe something like: "you must be logged in with your nsec to use one-click setup. your profile will not be shared with coinos"
created_at: 2025-04-17T04:12:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,340 @@
+//
+//  CoinosDeterministicClient.swift
+//  damus
+//
+//  Created by Daniel D’Aquino on 2025-04-14.
+//
+
+import Foundation
+
+/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
+///
+/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
+class CoinosDeterministicAccountClient {
+    // MARK: - State
+    
+    /// The user's normal keypair for using Nostr
+    private let userKeypair: FullKeypair
+    /// The JWT authentication token with Coinos
+    private var jwtAuthToken: String? = nil
+    
+    
+    // MARK: - Computed properties for a deterministic wallet
+    
+    /// A deterministic keypair for the NWC connection derived from the user's private key
+    private var nwcKeypair: FullKeypair? {
+        let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id))   // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
+        return FullKeypair(privkey: nwcPrivateKey)
+    }
+    
+    /// A deterministic username for a Coinos account
+    private var username: String? {
+        // Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
+        // Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
+        guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
+        // There is very little risk of a birthday attack on getting only the first 16 characters, because:
+        // 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
+        // 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
+        //
+        // In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
+        // According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
+        // even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
+        return String(fullText.prefix(16))
path: damus/Util/CoinosDeterministicAccountClient.swift
user: login: danieldaquino
body: That was for aesthetic reasons only. 32 hex-encoded bytes looks too long.

I was thinking about using deterministic mnemonic words (e.g. "ambitious.purple.duck@coinos.io"), but it's not really visible and we want to discourage users from trying to manually login their account, so maybe it's better to not use mnemonics either. It would also add complexity to the deterministic algorithm, so maybe it's best not to.
created_at: 2025-04-18T18:47:08Z
author_association: COLLABORATOR

PR comments:
user: login: danieldaquino
created_at: 2025-04-18T18:58:49Z
body: @jb55, @ericholguin, I updated the sheet, here is how it looks like now:

| 1 | 2 |
|--------|--------|
| ![Simulator Screenshot - iPhone SE (3rd generation) - 2025-04-18 at 11 42 40](https://github.com/user-attachments/assets/8c7bae66-2f42-4c9d-888f-8beda0a01ae3) | ![Simulator Screenshot - iPhone SE (3rd generation) - 2025-04-18 at 11 56 02](https://github.com/user-attachments/assets/ec63b95b-4f91-464f-8845-4bf72ba15345) |

Please let me know if you have any other suggestions! 
author_association: COLLABORATOR
user: login: danieldaquino
created_at: 2025-04-18T23:52:04Z
body: Added a new (optional) commit which adds a small disclaimer to clarify that Coinos is a third-party service. This is not legal advice, and I am not a legal expert — but I suppose it makes sense to add this, for the sake of clarity? cc @jb55

<img src="https://github.com/user-attachments/assets/6bcbf69c-5d20-4f4e-a32e-304b3096363e" width="300"/>


author_association: COLLABORATOR
user: login: jb55
created_at: 2025-04-24T14:13:32Z
body: On Fri, Apr 18, 2025 at 04:52:27PM -0700, Daniel D’Aquino wrote:
>danieldaquino left a comment (damus-io/damus#2982)
>
>Added a new (optional) commit which adds a small disclaimer to clarify that Coinos is a third-party service. This is not legal advice, and I am not a legal expert — but I suppose it makes sense to add this, for the sake of clarity? cc @jb55
>
><img src="https://github.com/user-attachments/assets/6bcbf69c-5d20-4f4e-a32e-304b3096363e" width="300"/>

It's fine but the "we" here is a bit ambiguous. The *client* has access, so I assume *we* here means Damus Inc. Not sure if there's a better wording though.

author_association: COLLABORATOR
user: login: danieldaquino
created_at: 2025-04-25T19:58:02Z
body: > It's fine but the "we" here is a bit ambiguous. The *client* has access, so I assume *we* here means Damus Inc. Not sure if there's a better wording though.

Good point. How about:

> Coinos is a service operated by a third-party. The Damus team has no access to your wallet.
author_association: COLLABORATOR
user: login: jb55
created_at: 2025-04-25T20:49:09Z
body: > > It's fine but the "we" here is a bit ambiguous. The _client_ has access, so I assume _we_ here means Damus Inc. Not sure if there's a better wording though.
> 
> Good point. How about:
> 
> > Coinos is a service operated by a third-party. The Damus team has no access to your wallet.

lgtm
author_association: COLLABORATOR