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>
This commit is contained in:
Daniel D’Aquino
2025-06-18 20:42:34 -07:00
parent 6f9a00d728
commit e9e68422d4
2 changed files with 255 additions and 0 deletions

View File

@@ -42,6 +42,11 @@ class CoinosDeterministicAccountClient {
return String(fullText.prefix(16)) 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 /// A deterministic password for a Coinos account
private var password: String? { private var password: String? {
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key // 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 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 /// Returns the default wallet connection config
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig { private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
guard let nwcKeypair else { throw ClientError.errorFormingRequest } guard let nwcKeypair else { throw ClientError.errorFormingRequest }

View File

@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Combine
struct NWCSettings: View { struct NWCSettings: View {
@@ -16,6 +17,18 @@ struct NWCSettings: View {
@Environment(\.dismiss) var dismiss @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> { func donation_binding() -> Binding<Double> {
return Binding(get: { 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) Toggle(NSLocalizedString("Hide balance", comment: "Setting to hide wallet balance."), isOn: $settings.hide_wallet_balance)
.toggleStyle(.switch) .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: { Button(action: {
self.model.disconnect() self.model.disconnect()
@@ -156,6 +238,10 @@ struct NWCSettings: View {
.padding() .padding()
.onAppear() { .onAppear() {
model.initial_percent = model.settings.donation_percent model.initial_percent = model.settings.donation_percent
checkIfCoinosWallet()
if isCoinosWallet {
fetchCurrentBudget()
}
} }
.onChange(of: model.settings.donation_percent) { p in .onChange(of: model.settings.donation_percent) { p in
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) 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 { struct AccountDetailsView: View {
let nwc: WalletConnect.ConnectURL let nwc: WalletConnect.ConnectURL
let damus_state: DamusState? 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 { 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) 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)
}
}