damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit e9e68422d4573bd9c89d9d0f9d76d12daf89f401
parent 6f9a00d728c2cdb370372f6841bcd77eb46ac39e
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Wed, 18 Jun 2025 20:42:34 -0700

Implement max budget setting for Coinos one-click wallets

Closes: https://github.com/damus-io/damus/issues/3059
Changelog-Added: Added adjustable max budget setting for Coinos one-click wallets
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus/Util/CoinosDeterministicAccountClient.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Wallet/NWCSettings.swift | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 255 insertions(+), 0 deletions(-)

diff --git a/damus/Util/CoinosDeterministicAccountClient.swift b/damus/Util/CoinosDeterministicAccountClient.swift @@ -42,6 +42,11 @@ class CoinosDeterministicAccountClient { return String(fullText.prefix(16)) } + var expectedLud16: String? { + guard let username else { return nil } + return username + "@coinos.io" + } + /// 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 @@ -163,6 +168,50 @@ class CoinosDeterministicAccountClient { throw ClientError.errorProcessingResponse } + /// Updates an existing NWC connection with a new maximum budget + /// + /// Note: Account and NWC connection must exist before calling this endpoint + func updateNWCConnection(maxAmount: UInt64) 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() + + // Get existing config first + guard let existingConfig = try await self.getNWCAppConnectionConfig() else { + throw ClientError.errorProcessingResponse + } + + // Create updated config with new max amount + let updatedConfig = NewWalletConnectionConfig( + name: existingConfig.name ?? self.nwcConnectionName, + secret: existingConfig.secret ?? nwcKeypair.privkey.hex(), + pubkey: existingConfig.pubkey ?? nwcKeypair.pubkey.hex(), + max_amount: maxAmount, + budget_renewal: .weekly + ) + + let configData = try encode_json_data(updatedConfig) + + 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 } diff --git a/damus/Views/Wallet/NWCSettings.swift b/damus/Views/Wallet/NWCSettings.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine struct NWCSettings: View { @@ -16,6 +17,18 @@ struct NWCSettings: View { @Environment(\.dismiss) var dismiss + // Budget sync state tracking + @State private var isCoinosWallet: Bool = false + @State private var maxWeeklyBudget: UInt64? = nil + @State private var budgetSyncState: BudgetSyncState = .undefined + + // Min/max budget values for slider + private let minBudget: UInt64 = 100 + private let maxBudget: UInt64 = 10_000_000 + + // Slider min/max values for logarithmic scale (0-1 range) + private let sliderMin: Double = 0.0 + private let sliderMax: Double = 1.0 func donation_binding() -> Binding<Double> { return Binding(get: { @@ -141,6 +154,75 @@ struct NWCSettings: View { Toggle(NSLocalizedString("Hide balance", comment: "Setting to hide wallet balance."), isOn: $settings.hide_wallet_balance) .toggleStyle(.switch) + + if isCoinosWallet, let maxWeeklyBudget { + VStack(alignment: .leading) { + Text("Max weekly budget", comment: "Label for setting the maximum weekly budget for Coinos wallet") + .font(.headline) + .padding(.bottom, 2) + Text("The maximum amount of funds that are allowed to be sent out from this wallet each week.", comment: "Description explaining the purpose of the 'Max weekly budget' setting for Coinos one-click setup wallets") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 10) { + HStack { + Slider( + // Use a logarithmic scale for this slider to give more control to different kinds of users: + // + // - Users with higher budget tolerance can select very high amounts (e.g. Easy to go up to 5M or 10M sats) + // - Conservative users can still have fine-grained control over lower amounts (e.g. Easy to switch between 500 and 1.5K sats) + value: Binding( + get: { + // Convert from budget value to slider position (0-1) + budgetToSliderPosition(budget: maxWeeklyBudget) + }, + set: { + // Convert from slider position to budget value + let newValue = sliderPositionToBudget(position: $0) + if self.maxWeeklyBudget != newValue { + self.maxWeeklyBudget = newValue + } + } + ), + in: sliderMin...sliderMax, + onEditingChanged: { editing in + if !editing { + updateMaxWeeklyBudget() + } + } + ) + + Text(verbatim: format_msats(Int64(maxWeeklyBudget) * 1000)) + .foregroundColor(.gray) + .frame(width: 150, alignment: .trailing) + } + + // Budget sync status + HStack { + switch budgetSyncState { + case .undefined: + EmptyView() + case .success: + HStack { + Image("check-circle.fill") + .foregroundStyle(.damusGreen) + Text("Successfully updated", comment: "Label indicating success in updating budget") + } + case .syncing: + HStack(spacing: 10) { + ProgressView() + Text("Updating", comment: "Label indicating budget update is in progress") + } + case .failure(let error): + Text(error) + .foregroundStyle(.damusDangerPrimary) + } + } + .padding(.top, 5) + } + } + .padding(.vertical, 8) + } Button(action: { self.model.disconnect() @@ -156,6 +238,10 @@ struct NWCSettings: View { .padding() .onAppear() { model.initial_percent = model.settings.donation_percent + checkIfCoinosWallet() + if isCoinosWallet { + fetchCurrentBudget() + } } .onChange(of: model.settings.donation_percent) { p in let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) @@ -186,6 +272,79 @@ struct NWCSettings: View { } } + // Check if the current wallet is a Coinos one-click wallet + private func checkIfCoinosWallet() { + // Check condition 1: Relay is coinos.io + let isRelayCoinos = nwc.relay.absoluteString == "wss://relay.coinos.io" + + // Check condition 2: LUD16 matches expected format + guard let keypair = damus_state.keypair.to_full() else { + isCoinosWallet = false + return + } + + let client = CoinosDeterministicAccountClient(userKeypair: keypair) + let expectedLud16 = client.expectedLud16 + + isCoinosWallet = isRelayCoinos && nwc.lud16 == expectedLud16 + } + + /// Fetches the current max weekly budget from Coinos + private func fetchCurrentBudget() { + guard let keypair = damus_state.keypair.to_full() else { return } + + let client = CoinosDeterministicAccountClient(userKeypair: keypair) + + Task { + do { + if let config = try await client.getNWCAppConnectionConfig(), + let maxAmount = config.max_amount { + DispatchQueue.main.async { + self.maxWeeklyBudget = maxAmount + } + } + } catch { + self.budgetSyncState = .failure(error: error.localizedDescription) + } + } + } + + /// Updates the max weekly budget on Coinos + private func updateMaxWeeklyBudget() { + guard let maxWeeklyBudget else { return } + guard let keypair = damus_state.keypair.to_full() else { return } + + budgetSyncState = .syncing + + let client = CoinosDeterministicAccountClient(userKeypair: keypair) + + Task { + do { + // First ensure we're logged in + try await client.loginIfNeeded() + + // Update the connection with the new budget + _ = try await client.updateNWCConnection(maxAmount: maxWeeklyBudget) + + DispatchQueue.main.async { + self.budgetSyncState = .success + + // Reset success state after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + if case .success = self.budgetSyncState { + self.budgetSyncState = .undefined + } + } + } + + } catch { + DispatchQueue.main.async { + self.budgetSyncState = .failure(error: error.localizedDescription) + } + } + } + } + struct AccountDetailsView: View { let nwc: WalletConnect.ConnectURL let damus_state: DamusState? @@ -233,6 +392,40 @@ struct NWCSettings: View { ) } } + + + // MARK: - Logarithmic scale conversions + + /// Converts from budget value to a slider position (0-1 range) + func budgetToSliderPosition(budget: UInt64) -> Double { + // Ensure budget is within bounds + let clampedBudget = max(minBudget, min(maxBudget, budget)) + + // Calculate the log scale position + let minLog = log10(Double(minBudget)) + let maxLog = log10(Double(maxBudget)) + let budgetLog = log10(Double(clampedBudget)) + + // Convert to 0-1 range + return (budgetLog - minLog) / (maxLog - minLog) + } + + // Convert from slider position (0-1) to budget value + func sliderPositionToBudget(position: Double) -> UInt64 { + // Ensure position is within bounds + let clampedPosition = max(sliderMin, min(sliderMax, position)) + + // Calculate the log scale value + let minLog = log10(Double(minBudget)) + let maxLog = log10(Double(maxBudget)) + let valueLog = minLog + clampedPosition * (maxLog - minLog) + + // Convert to budget value and round to nearest 100 to make the number look "cleaner" + let exactValue = pow(10, valueLog) + let roundedValue = round(exactValue / 100) * 100 + + return UInt64(roundedValue) + } } struct NWCSettings_Previews: PreviewProvider { @@ -241,3 +434,16 @@ struct NWCSettings_Previews: PreviewProvider { NWCSettings(damus_state: tds, nwc: test_wallet_connect_url, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings), settings: tds.settings) } } + +extension NWCSettings { + enum BudgetSyncState: Equatable { + /// State is unknown + case undefined + /// Budget is successfully updated + case success + /// Budget is being updated + case syncing + /// There was a failure during update + case failure(error: String) + } +}