Skip to content

Commit 8008084

Browse files
committed
Add one-click Coinos wallet setup
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. Closes: #2961 Changelog-Added: Added one-click Coinos wallet setup Signed-off-by: Daniel D’Aquino <[email protected]>
1 parent d1cced8 commit 8008084

File tree

5 files changed

+461
-5
lines changed

5 files changed

+461
-5
lines changed

damus.xcodeproj/project.pbxproj

+8
Original file line numberDiff line numberDiff line change
@@ -1649,6 +1649,9 @@
16491649
D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
16501650
D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B93D2A9AD44700DC3548 /* Message.swift */; };
16511651
D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */; };
1652+
D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
1653+
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
1654+
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
16521655
D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
16531656
D7D68FF92C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
16541657
D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
@@ -2554,6 +2557,7 @@
25542557
D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
25552558
D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = "<group>"; };
25562559
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = "<group>"; };
2560+
D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosDeterministicAccountClient.swift; sourceTree = "<group>"; };
25572561
D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationClient.swift; sourceTree = "<group>"; };
25582562
D7D68FF82C9E01B60015A515 /* KFClickable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFClickable.swift; sourceTree = "<group>"; };
25592563
D7DB1FDD2D5A78CE00CF06DA /* NIP44.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP44.swift; sourceTree = "<group>"; };
@@ -3363,6 +3367,7 @@
33633367
D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */,
33643368
D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */,
33653369
D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */,
3370+
D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */,
33663371
);
33673372
path = Util;
33683373
sourceTree = "<group>";
@@ -4859,6 +4864,7 @@
48594864
4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */,
48604865
4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */,
48614866
4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */,
4867+
D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
48624868
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */,
48634869
4C363A962827096D006E126D /* PostBlock.swift in Sources */,
48644870
4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */,
@@ -5247,6 +5253,7 @@
52475253
82D6FB652CD99F7900C925F4 /* CollectionExtension.swift in Sources */,
52485254
82D6FB662CD99F7900C925F4 /* ZapDataModel.swift in Sources */,
52495255
82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
5256+
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
52505257
82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
52515258
82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
52525259
82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
@@ -5996,6 +6003,7 @@
59966003
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
59976004
D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
59986005
D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
6006+
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
59996007
D703D7802C670C2500A400EA /* NIP05.swift in Sources */,
60006008
D703D7AA2C670E5D00A400EA /* verifier.c in Sources */,
60016009
D73E5E1D2C6A9680007EB227 /* PreviewCache.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
//
2+
// CoinosDeterministicClient.swift
3+
// damus
4+
//
5+
// Created by Daniel D’Aquino on 2025-04-14.
6+
//
7+
8+
import Foundation
9+
10+
/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
11+
///
12+
/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
13+
class CoinosDeterministicAccountClient {
14+
// MARK: - State
15+
16+
/// The user's normal keypair for using Nostr
17+
private let userKeypair: FullKeypair
18+
/// The JWT authentication token with Coinos
19+
private var jwtAuthToken: String? = nil
20+
21+
22+
// MARK: - Computed properties for a deterministic wallet
23+
24+
/// A deterministic keypair for the NWC connection derived from the user's private key
25+
private var nwcKeypair: FullKeypair? {
26+
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
27+
return FullKeypair(privkey: nwcPrivateKey)
28+
}
29+
30+
/// A deterministic username for a Coinos account
31+
private var username: String? {
32+
// Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
33+
// Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
34+
guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
35+
// There is very little risk of a birthday attack on getting only the first 16 characters, because:
36+
// 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
37+
// 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
38+
//
39+
// In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
40+
// According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
41+
// even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
42+
return String(fullText.prefix(16))
43+
}
44+
45+
/// A deterministic password for a Coinos account
46+
private var password: String? {
47+
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
48+
return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
49+
}
50+
51+
/// A deterministic NWC app connection name
52+
private var nwcConnectionName: String { return "Damus" }
53+
54+
55+
// MARK: - Initialization
56+
57+
/// Initializes the client with the user's keypair
58+
init(userKeypair: FullKeypair) {
59+
self.userKeypair = userKeypair
60+
}
61+
62+
63+
// MARK: - Authentication and registration
64+
65+
/// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
66+
func loginOrRegister() async throws {
67+
do {
68+
// Check if client has an account
69+
try await self.login()
70+
}
71+
catch {
72+
guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
73+
// Client does not seem to have an account, create one
74+
try await self.register()
75+
try await self.login()
76+
}
77+
}
78+
79+
/// Registers for a Coinos account using deterministic account details.
80+
///
81+
/// It succeeds if it returns without throwing errors.
82+
func register() async throws {
83+
guard let username, let password else { throw ClientError.errorFormingRequest }
84+
let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
85+
let jsonData = try JSONEncoder().encode(registerPayload)
86+
87+
let url = URL(string: "https://coinos.io/api/register")!
88+
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
89+
90+
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
91+
return
92+
} else {
93+
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
94+
}
95+
}
96+
97+
/// Logs into the deterministic account, if an auth token is not present
98+
func loginIfNeeded() async throws {
99+
if self.jwtAuthToken == nil { try await self.login() }
100+
}
101+
102+
/// Logs into to our deterministic account.
103+
///
104+
/// Succeeds if it returns without returning errors.
105+
///
106+
/// Mutating function, will update the client's internal state.
107+
func login() async throws {
108+
self.jwtAuthToken = try await sendLoginRequest().token
109+
}
110+
111+
/// Sends the login request and return the response
112+
///
113+
/// Does NOT update the internal login state.
114+
private func sendLoginRequest() async throws -> AuthResponse {
115+
guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
116+
guard let username, let password else { throw ClientError.errorFormingRequest }
117+
let credentials = UserCredentials(username: username, password: password)
118+
let jsonData = try JSONEncoder().encode(credentials)
119+
120+
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
121+
122+
if let httpResponse = response as? HTTPURLResponse {
123+
switch httpResponse.statusCode {
124+
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
125+
case 401: throw ClientError.unauthorized
126+
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
127+
}
128+
}
129+
throw ClientError.errorProcessingResponse
130+
}
131+
132+
133+
// MARK: - Managing NWC connections
134+
135+
/// Creates a new NWC connection
136+
///
137+
/// Note: Account must exist before calling this endpoint
138+
func createNWCConnection() async throws -> WalletConnectURL {
139+
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
140+
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
141+
142+
try await self.loginIfNeeded()
143+
144+
let config = try defaultWalletConnectionConfig()
145+
let configData = try encode_json_data(config)
146+
147+
let (data, response) = try await self.makeAuthenticatedRequest(
148+
method: .post,
149+
url: urlEndpoint,
150+
payload: configData,
151+
payload_type: .json
152+
)
153+
154+
if let httpResponse = response as? HTTPURLResponse {
155+
switch httpResponse.statusCode {
156+
case 200:
157+
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
158+
return nwc
159+
case 401: throw ClientError.unauthorized
160+
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
161+
}
162+
}
163+
throw ClientError.errorProcessingResponse
164+
}
165+
166+
/// Returns the default wallet connection config
167+
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
168+
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
169+
return NewWalletConnectionConfig(
170+
name: self.nwcConnectionName,
171+
secret: nwcKeypair.privkey.hex(),
172+
pubkey: nwcKeypair.pubkey.hex(),
173+
max_amount: 30000, // 30K sats per week maximum
174+
budget_renewal: .weekly
175+
)
176+
}
177+
178+
/// Gets the NWC URL for the deterministic NWC app connection
179+
///
180+
/// Account must already exist before calling this
181+
///
182+
/// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
183+
func getNWCUrl() async throws -> WalletConnectURL? {
184+
guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
185+
return WalletConnectURL(str: nwc)
186+
}
187+
188+
/// Gets the deterministic NWC app connection configuration details, if it exists
189+
///
190+
/// Account must already exist before calling this
191+
///
192+
/// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
193+
func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
194+
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
195+
guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
196+
197+
try await self.loginIfNeeded()
198+
199+
let (data, response) = try await self.makeAuthenticatedRequest(
200+
method: .get,
201+
url: url,
202+
payload: nil,
203+
payload_type: nil
204+
)
205+
206+
if let httpResponse = response as? HTTPURLResponse {
207+
switch httpResponse.statusCode {
208+
case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
209+
case 401: throw ClientError.unauthorized
210+
case 404: return nil
211+
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
212+
}
213+
}
214+
throw ClientError.errorProcessingResponse
215+
}
216+
217+
218+
// MARK: - Lower level request convenience functions
219+
220+
/// Makes a request without any authorization
221+
func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
222+
var request = URLRequest(url: url)
223+
request.httpMethod = method.rawValue
224+
request.httpBody = payload
225+
226+
if let payload_type {
227+
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
228+
}
229+
return try await URLSession.shared.data(for: request)
230+
}
231+
232+
/// Makes an authenticated request with our JWT auth token.
233+
///
234+
/// Client must be logged-in before calling this, otherwise an error will be thrown.
235+
func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
236+
guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
237+
238+
var request = URLRequest(url: url)
239+
request.httpMethod = method.rawValue
240+
request.httpBody = payload
241+
242+
request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
243+
if let payload_type {
244+
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
245+
}
246+
return try await URLSession.shared.data(for: request)
247+
}
248+
249+
250+
// MARK: - Helper structures
251+
252+
/// Payload for registering for a new Coinos account
253+
struct RegisterRequest: Codable {
254+
/// New user credentials
255+
let user: UserCredentials
256+
}
257+
258+
/// Payload for user credentials (sign-up and login)
259+
struct UserCredentials: Codable {
260+
/// The username
261+
let username: String
262+
/// The user password
263+
let password: String
264+
}
265+
266+
/// A successful response to a login auth endpoint
267+
struct AuthResponse: Codable {
268+
/// The JWT token to be applied to any authenticated API calls
269+
let token: String
270+
}
271+
272+
/// Used by the client to define new NWC configurations
273+
struct NewWalletConnectionConfig: Codable {
274+
/// The name of the connection
275+
let name: String
276+
/// 32 Hex-encoded bytes containing a shared private key secret
277+
let secret: String
278+
/// 32 Hex-encoded bytes containing the pubkey for the secret
279+
let pubkey: String
280+
/// Max amount that can be spent in each renewal period (measured in sats)
281+
let max_amount: UInt64
282+
/// The period of time it takes for the budget limits to reset
283+
let budget_renewal: BudgetRenewalPeriod
284+
}
285+
286+
/// The NWC connection configuration details
287+
///
288+
/// ## Implementation notes
289+
///
290+
/// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
291+
struct WalletConnectionConfig: Codable {
292+
/// The name of the connection
293+
let name: String?
294+
/// 32 Hex-encoded bytes containing a shared private key secret
295+
let secret: String?
296+
/// 32 Hex-encoded bytes containing the pubkey for the secret
297+
let pubkey: String?
298+
/// Max amount that can be spent in every renewal period (measured in sats)
299+
let max_amount: UInt64?
300+
/// The NWC url generated by the server
301+
let nwc: String?
302+
/// Budget renewal information
303+
let budget_renewal: BudgetRenewalPeriod?
304+
}
305+
306+
/// A period of time it takes for budget limits to be reset
307+
enum BudgetRenewalPeriod: String, Codable {
308+
/// Resets once a week
309+
case weekly
310+
}
311+
312+
/// A client error occured
313+
enum ClientError: Error, Equatable {
314+
/// Received an unexpected HTTP response
315+
///
316+
/// Could be for a variety of reasons.
317+
case unexpectedHTTPResponse(status_code: Int, response: Data)
318+
/// Error forming the request, generally due to missing or inconsistent internal data
319+
///
320+
/// Probably caused by a programming error.
321+
case errorFormingRequest
322+
/// The client could not process the response from the server
323+
///
324+
/// Might be a sign of an incompatibility bug
325+
case errorProcessingResponse
326+
/// The action performed is not authorized
327+
/// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
328+
case unauthorized
329+
/// Client not logged in on a call that expected login
330+
case notLoggedIn
331+
}
332+
}
333+
334+
/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
335+
///
336+
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
337+
fileprivate func sha256Hex(text: String) -> String? {
338+
guard let data = text.data(using: .utf8) else { return nil }
339+
return sha256(data).toHexString()
340+
}

0 commit comments

Comments
 (0)