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: https://github.com/damus-io/damus/issues/2961 Changelog-Added: Added one-click Coinos wallet setup Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
@@ -1649,6 +1649,9 @@
|
|||||||
D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
|
D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
|
||||||
D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B93D2A9AD44700DC3548 /* Message.swift */; };
|
D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B93D2A9AD44700DC3548 /* Message.swift */; };
|
||||||
D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9BB83029C0ED4F00FC4E37 /* DisplayName.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 */; };
|
D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
|
||||||
D7D68FF92C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
|
D7D68FF92C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
|
||||||
D7D68FFA2C9E01BE0015A515 /* 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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D7DB1FDD2D5A78CE00CF06DA /* NIP44.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP44.swift; sourceTree = "<group>"; };
|
||||||
@@ -3363,6 +3367,7 @@
|
|||||||
D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */,
|
D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */,
|
||||||
D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */,
|
D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */,
|
||||||
D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */,
|
D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */,
|
||||||
|
D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */,
|
||||||
);
|
);
|
||||||
path = Util;
|
path = Util;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -4859,6 +4864,7 @@
|
|||||||
4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */,
|
4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */,
|
||||||
4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */,
|
4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */,
|
||||||
4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */,
|
4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */,
|
||||||
|
D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
|
||||||
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */,
|
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */,
|
||||||
4C363A962827096D006E126D /* PostBlock.swift in Sources */,
|
4C363A962827096D006E126D /* PostBlock.swift in Sources */,
|
||||||
4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */,
|
4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */,
|
||||||
@@ -5247,6 +5253,7 @@
|
|||||||
82D6FB652CD99F7900C925F4 /* CollectionExtension.swift in Sources */,
|
82D6FB652CD99F7900C925F4 /* CollectionExtension.swift in Sources */,
|
||||||
82D6FB662CD99F7900C925F4 /* ZapDataModel.swift in Sources */,
|
82D6FB662CD99F7900C925F4 /* ZapDataModel.swift in Sources */,
|
||||||
82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
|
82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
|
||||||
|
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
|
||||||
82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
|
82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
|
||||||
82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
|
82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
|
||||||
82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
|
82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
|
||||||
@@ -5996,6 +6003,7 @@
|
|||||||
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
|
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
|
||||||
D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
|
D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
|
||||||
D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
|
D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
|
||||||
|
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
|
||||||
D703D7802C670C2500A400EA /* NIP05.swift in Sources */,
|
D703D7802C670C2500A400EA /* NIP05.swift in Sources */,
|
||||||
D703D7AA2C670E5D00A400EA /* verifier.c in Sources */,
|
D703D7AA2C670E5D00A400EA /* verifier.c in Sources */,
|
||||||
D73E5E1D2C6A9680007EB227 /* PreviewCache.swift in Sources */,
|
D73E5E1D2C6A9680007EB227 /* PreviewCache.swift in Sources */,
|
||||||
|
|||||||
340
damus/Util/CoinosDeterministicAccountClient.swift
Normal file
340
damus/Util/CoinosDeterministicAccountClient.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ struct ConnectWalletView: View {
|
|||||||
@State var error: String? = nil
|
@State var error: String? = nil
|
||||||
@State var wallet_scan_result: WalletScanResult = .scanning
|
@State var wallet_scan_result: WalletScanResult = .scanning
|
||||||
@State var show_introduction: Bool = true
|
@State var show_introduction: Bool = true
|
||||||
|
@State var show_coinos_options: Bool = false
|
||||||
var nav: NavigationCoordinator
|
var nav: NavigationCoordinator
|
||||||
|
let userKeypair: Keypair
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
MainContent
|
MainContent
|
||||||
@@ -147,8 +149,7 @@ struct ConnectWalletView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
CoinosButton() {
|
CoinosButton() {
|
||||||
show_introduction = false
|
self.show_coinos_options = true
|
||||||
openURL(URL(string:"https://coinos.io/settings/nostr")!)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -161,6 +162,110 @@ struct ConnectWalletView: View {
|
|||||||
.padding(2) // Avoids border clipping on the sides
|
.padding(2) // Avoids border clipping on the sides
|
||||||
)
|
)
|
||||||
.padding(.top, 20)
|
.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 {
|
var ManualSetup: some View {
|
||||||
@@ -270,7 +375,7 @@ struct ConnectWalletView: View {
|
|||||||
|
|
||||||
struct ConnectWalletView_Previews: PreviewProvider {
|
struct ConnectWalletView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
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")
|
.previewDisplayName("Main Wallet Connect View")
|
||||||
ConnectWalletView.AreYouSure(nwc: get_test_nwc(), show_introduction: .constant(false), model: WalletModel(settings: test_damus_state.settings))
|
ConnectWalletView.AreYouSure(nwc: get_test_nwc(), show_introduction: .constant(false), model: WalletModel(settings: test_damus_state.settings))
|
||||||
.previewDisplayName("Are you sure screen")
|
.previewDisplayName("Are you sure screen")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct NWCSettings: View {
|
|||||||
@ObservedObject var model: WalletModel
|
@ObservedObject var model: WalletModel
|
||||||
@ObservedObject var settings: UserSettingsStore
|
@ObservedObject var settings: UserSettingsStore
|
||||||
|
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
|
||||||
func donation_binding() -> Binding<Double> {
|
func donation_binding() -> Binding<Double> {
|
||||||
return Binding(get: {
|
return Binding(get: {
|
||||||
@@ -136,6 +138,7 @@ struct NWCSettings: View {
|
|||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
self.model.disconnect()
|
self.model.disconnect()
|
||||||
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.")
|
Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.")
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ struct WalletView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
switch model.connect_state {
|
switch model.connect_state {
|
||||||
case .new:
|
case .new:
|
||||||
ConnectWalletView(model: model, nav: damus_state.nav)
|
ConnectWalletView(model: model, nav: damus_state.nav, userKeypair: self.damus_state.keypair)
|
||||||
case .none:
|
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):
|
case .existing(let nwc):
|
||||||
MainWalletView(nwc: nwc)
|
MainWalletView(nwc: nwc)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
Reference in New Issue
Block a user