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:
@@ -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 }
|
||||
|
||||
@@ -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: {
|
||||
@@ -142,6 +155,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()
|
||||
dismiss()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user