diff --git a/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletDatabaseRecovery.kt b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletDatabaseRecovery.kt new file mode 100644 index 0000000..b4a4726 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletDatabaseRecovery.kt @@ -0,0 +1,10 @@ +package org.cashu.wallet.Core + +internal fun shouldAttemptWalletDatabaseRecovery(error: Throwable): Boolean { + val normalized = (error.message ?: error.toString()).lowercase() + return normalized.contains("sqlite") || + normalized.contains("database") || + normalized.contains("corrupt") || + normalized.contains("malformed") || + normalized.contains("walletdb") +} diff --git a/android/app/src/main/java/org/cashu/wallet/Core/WalletManager.kt b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletManager.kt similarity index 62% rename from android/app/src/main/java/org/cashu/wallet/Core/WalletManager.kt rename to android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletManager.kt index cce0187..9e0530f 100644 --- a/android/app/src/main/java/org/cashu/wallet/Core/WalletManager.kt +++ b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletManager.kt @@ -1,6 +1,5 @@ package org.cashu.wallet.Core -import java.net.HttpURLConnection import java.net.URL import java.util.UUID import kotlinx.coroutines.CoroutineExceptionHandler @@ -13,10 +12,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import org.cashu.wallet.Core.CDK.CdkWalletGateway import org.cashu.wallet.Core.Platform.WalletDatabasePathManager import org.cashu.wallet.Core.Protocols.SecureStorage @@ -26,35 +21,11 @@ import org.cashu.wallet.Models.MeltPaymentResult import org.cashu.wallet.Models.MeltQuoteInfo import org.cashu.wallet.Models.MintInfo import org.cashu.wallet.Models.MintQuoteInfo -import org.cashu.wallet.Models.MintQuoteState import org.cashu.wallet.Models.PaymentMethodKind -import org.cashu.wallet.Models.ClaimedToken import org.cashu.wallet.Models.PendingReceiveToken import org.cashu.wallet.Models.PendingToken import org.cashu.wallet.Models.RestoreMintResult import org.cashu.wallet.Models.SendTokenResult -import org.cashu.wallet.Models.TransactionKind -import org.cashu.wallet.Models.TransactionStatus -import org.cashu.wallet.Models.TransactionType -import org.cashu.wallet.Models.WalletTransaction - -data class WalletState( - val balance: Long = 0, - val pendingBalance: Long = 0, - val isInitialized: Boolean = false, - val needsOnboarding: Boolean = true, - val canExitOnboarding: Boolean = false, - val isLoading: Boolean = false, - val errorMessage: String? = null, - val activeUnit: String = "sat", - val mints: List = emptyList(), - val activeMint: MintInfo? = null, - val transactions: List = emptyList(), - val pendingTokens: List = emptyList(), - val pendingReceiveTokens: List = emptyList(), - val claimedTokens: List = emptyList(), - val transactionUpdateVersion: Long = 0, -) class WalletManager( private val secureStorage: SecureStorage, @@ -72,7 +43,9 @@ class WalletManager( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + exceptionHandler) private val mutableState = MutableStateFlow(WalletState()) val state: StateFlow = mutableState.asStateFlow() - private val mintQuoteSyncsInFlight = mutableSetOf() + private val mintMetadataFetcher = WalletMintMetadataFetcher() + private val mintQuoteSyncService = WalletMintQuoteSyncService(gateway, walletStore) + private val transactionLoader = WalletTransactionLoader(walletStore, gateway) private val npcQuotesInFlight = mutableSetOf() private var processedNPCQuotes = walletStore.loadProcessedNPCQuotes().toMutableSet() @@ -177,14 +150,14 @@ class WalletManager( override suspend fun addMint(url: String) { withLoading { - val normalized = normalizeMintUrl(url) - validateMintUrl(normalized)?.let { throw IllegalArgumentException(it) } + val normalized = mintMetadataFetcher.normalizeMintUrl(url) + mintMetadataFetcher.validateMintUrl(normalized)?.let { throw IllegalArgumentException(it) } if (mutableState.value.mints.any { it.url == normalized }) { throw IllegalArgumentException("Mint already exists.") } runCatching { gateway.ensureWallet(normalized) } .onFailure { AppLogger.wallet.error("CDK wallet preparation is not available yet for $normalized", it) } - val fetched = gateway.fetchMintInfo(normalized) ?: fetchRawMintInfo(normalized) + val fetched = gateway.fetchMintInfo(normalized) ?: mintMetadataFetcher.fetchRawMintInfo(normalized) val updated = mutableState.value.mints + fetched walletStore.saveMints(updated) if (mutableState.value.activeMint == null) walletStore.activeMintURL = fetched.url @@ -214,8 +187,8 @@ class WalletManager( override suspend fun restoreFromMint(url: String): RestoreMintResult = withLoadingResult { - val normalized = normalizeMintUrl(url) - validateMintUrl(normalized)?.let { throw IllegalArgumentException(it) } + val normalized = mintMetadataFetcher.normalizeMintUrl(url) + mintMetadataFetcher.validateMintUrl(normalized)?.let { throw IllegalArgumentException(it) } val trackedMintUrl = ensureMintTracked(normalized) val result = withContext(Dispatchers.IO) { gateway.restoreMint(trackedMintUrl) } refreshBalance() @@ -244,15 +217,23 @@ class WalletManager( override suspend fun createMintQuote(amount: Long?, method: PaymentMethodKind): MintQuoteInfo { val active = mutableState.value.activeMint ?: throw IllegalStateException("No active mint.") return withLoadingResult { - gateway.createMintQuote(amount, method, active.url).also { rememberMintQuoteTimestamp(it.id) } + gateway.createMintQuote(amount, method, active.url).also { + mintQuoteSyncService.rememberMintQuoteTimestamp(it.id) + } } } suspend fun checkMintQuote(quoteId: String): MintQuoteInfo = - withLoadingResult { gateway.checkMintQuote(quoteId).also { rememberMintQuoteTimestamp(it.id) } } + withLoadingResult { + gateway.checkMintQuote(quoteId).also { + mintQuoteSyncService.rememberMintQuoteTimestamp(it.id) + } + } suspend fun pollMintQuote(quoteId: String): MintQuoteInfo = - gateway.checkMintQuote(quoteId).also { rememberMintQuoteTimestamp(it.id) } + gateway.checkMintQuote(quoteId).also { + mintQuoteSyncService.rememberMintQuoteTimestamp(it.id) + } fun subscribeToMintQuote(quoteId: String): Flow = gateway.subscribeToMintQuote(quoteId) @@ -266,7 +247,10 @@ class WalletManager( suspend fun refreshPendingMintQuote(quoteId: String): Boolean = withLoadingResult { - val minted = syncPendingMintQuote(quoteId, allowPendingOnchainMintAttempt = true) + val minted = mintQuoteSyncService.syncPendingMintQuote( + quoteId, + allowPendingOnchainMintAttempt = true, + ) if (minted) refreshBalance() loadTransactions() minted @@ -278,7 +262,12 @@ class WalletManager( .getOrDefault(emptyList()) var mintedCount = 0 pendingQuotes.forEach { quote -> - if (syncPendingMintQuote(quote.id, allowPendingOnchainMintAttempt = false)) { + if ( + mintQuoteSyncService.syncPendingMintQuote( + quote.id, + allowPendingOnchainMintAttempt = false, + ) + ) { mintedCount += 1 } } @@ -304,7 +293,7 @@ class WalletManager( loadTransactions() amount > 0 || isNPCQuoteProcessed(quote.id) } catch (error: Throwable) { - if (isAlreadyIssuedMintError(error)) { + if (mintQuoteSyncService.isAlreadyIssuedMintError(error)) { markNPCQuoteProcessed(quote.id) true } else { @@ -322,7 +311,7 @@ class WalletManager( override suspend fun meltTokens(quoteId: String, mintUrl: String?): MeltPaymentResult = withLoadingResult { val result = gateway.meltTokens(quoteId, mintUrl) - saveMeltPaymentMetadata(quoteId, result) + transactionLoader.saveMeltPaymentMetadata(quoteId, result) refreshBalance() loadTransactions() result @@ -402,7 +391,13 @@ class WalletManager( withLoadingResult { val claimed = gateway.checkTokenSpendable(pendingToken.token, pendingToken.mintUrl) if (claimed) { - markPendingTokenClaimed(pendingToken) + val mutation = transactionLoader.markPendingTokenClaimed(pendingToken) + update { + copy( + pendingTokens = mutation.pendingTokens, + claimedTokens = mutation.claimedTokens, + ) + } loadTransactions() } claimed @@ -416,7 +411,13 @@ class WalletManager( val claimed = runCatching { gateway.checkTokenSpendable(token.token, token.mintUrl) } .getOrDefault(false) if (claimed) { - markPendingTokenClaimed(token) + val mutation = transactionLoader.markPendingTokenClaimed(token) + update { + copy( + pendingTokens = mutation.pendingTokens, + claimedTokens = mutation.claimedTokens, + ) + } claimedCount += 1 } } @@ -446,60 +447,13 @@ class WalletManager( suspend fun loadTransactions() { val mintUrls = mutableState.value.mints.map { it.url } - val trackedMintUrls = mintUrls.toSet() - val preimages = walletStore.loadPaymentPreimages() - val meltFees = walletStore.loadMeltQuoteFees() - val pendingTokens = walletStore.loadPendingTokens() - val pendingReceiveTokens = walletStore.loadPendingReceiveTokens() - val claimedTokens = walletStore.loadClaimedTokens() - val remote = runCatching { gateway.listTransactions(mintUrls) }.getOrDefault(emptyList()) - .map { it.withStoredMeltMetadata(preimages, meltFees) } - val completedQuoteIds = remote.mapNotNull { it.quoteId }.toSet() - val mintQuoteTimestamps = walletStore.loadMintQuoteTimestamps().toMutableMap() - val pendingQuoteTransactions = observePendingOnchainMintQuotes( - runCatching { gateway.listUnissuedMintQuotes() } - .getOrDefault(emptyList()) - .let { quotes -> - pendingMintQuoteTransactions( - quotes = quotes, - trackedMintUrls = trackedMintUrls, - completedQuoteIds = completedQuoteIds, - timestamps = mintQuoteTimestamps, - nowEpochMillis = System.currentTimeMillis(), - ) - } - .map { it.withStoredMeltMetadata(preimages, meltFees) }, - ) - val storedMeltTransactions = runCatching { gateway.listMeltQuotes() } - .getOrDefault(emptyList()) - .let { quotes -> - storedMeltQuoteTransactions( - quotes = quotes, - trackedMintUrls = trackedMintUrls, - completedQuoteIds = completedQuoteIds, - timestamps = mintQuoteTimestamps, - nowEpochMillis = System.currentTimeMillis(), - preimages = preimages, - fees = meltFees, - ) - } - val tokenTransactions = pendingSentTokenTransactions(pendingTokens) + - pendingReceiveTokenTransactions(pendingReceiveTokens) + - claimedTokenTransactions(claimedTokens) - val cached = walletStore.loadTransactions() - .filterNot { it.isPendingToken } - .map { it.withStoredMeltMetadata(preimages, meltFees) } - val merged = (remote + pendingQuoteTransactions + storedMeltTransactions + tokenTransactions + cached) - .distinctBy { "${it.mintUrl.orEmpty()}|${it.quoteId ?: it.id}" } - .sortedByDescending { it.dateEpochMillis } - walletStore.saveTransactions(merged) - walletStore.saveMintQuoteTimestamps(pruneMintQuoteTimestamps(merged, mintQuoteTimestamps)) + val result = transactionLoader.load(mintUrls) update { copy( - transactions = merged, - pendingTokens = pendingTokens, - pendingReceiveTokens = pendingReceiveTokens, - claimedTokens = claimedTokens, + transactions = result.transactions, + pendingTokens = result.pendingTokens, + pendingReceiveTokens = result.pendingReceiveTokens, + claimedTokens = result.claimedTokens, transactionUpdateVersion = nextTransactionUpdateVersion(transactionUpdateVersion), ) } @@ -598,187 +552,25 @@ class WalletManager( } } - private fun markPendingTokenClaimed(pendingToken: PendingToken) { - val pending = walletStore.loadPendingTokens().filterNot { it.tokenId == pendingToken.tokenId } - val claimedToken = ClaimedToken( - tokenId = pendingToken.tokenId, - token = pendingToken.token, - amount = pendingToken.amount, - fee = pendingToken.fee, - dateEpochMillis = pendingToken.dateEpochMillis, - mintUrl = pendingToken.mintUrl, - memo = pendingToken.memo, - claimedDateEpochMillis = System.currentTimeMillis(), - ) - val claimed = walletStore.loadClaimedTokens() - .filterNot { it.tokenId == pendingToken.tokenId } + claimedToken - walletStore.savePendingTokens(pending) - walletStore.saveClaimedTokens(claimed) - update { copy(pendingTokens = pending, claimedTokens = claimed) } - } - private fun markNPCQuoteProcessed(quoteId: String) { processedNPCQuotes += quoteId walletStore.saveProcessedNPCQuotes(processedNPCQuotes.sorted()) } - private fun saveMeltPaymentMetadata(quoteId: String, result: MeltPaymentResult) { - result.preimage?.let { preimage -> - walletStore.savePaymentPreimages(walletStore.loadPaymentPreimages() + (quoteId to preimage)) - } - walletStore.saveMeltQuoteFees(walletStore.loadMeltQuoteFees() + (quoteId to result.feePaid)) - - val current = walletStore.loadTransactions() - val existing = current.firstOrNull { it.quoteId == quoteId || it.id == quoteId } - val transaction = WalletTransaction( - id = existing?.id ?: quoteId, - amount = result.amount, - type = TransactionType.Outgoing, - kind = when (result.paymentMethod) { - PaymentMethodKind.Onchain -> TransactionKind.Onchain - else -> TransactionKind.Lightning - }, - dateEpochMillis = existing?.dateEpochMillis ?: System.currentTimeMillis(), - memo = existing?.memo, - status = TransactionStatus.Completed, - mintUrl = result.mintUrl, - preimage = result.preimage ?: existing?.preimage, - invoice = result.request ?: existing?.invoice, - fee = result.feePaid, - quoteId = quoteId, - ) - walletStore.saveTransactions( - listOf(transaction) + current.filterNot { it.id == transaction.id || it.quoteId == quoteId }, - ) - } - - private suspend fun syncPendingMintQuote( - quoteId: String, - allowPendingOnchainMintAttempt: Boolean, - ): Boolean { - if (!mintQuoteSyncsInFlight.add(quoteId)) return false - return try { - val updatedQuote = gateway.checkMintQuote(quoteId).also { rememberMintQuoteTimestamp(it.id) } - val shouldAttemptMint = updatedQuote.state == MintQuoteState.Paid || - updatedQuote.state == MintQuoteState.Issued || - (allowPendingOnchainMintAttempt && updatedQuote.paymentMethod == PaymentMethodKind.Onchain) - if (!shouldAttemptMint) return false - - if (updatedQuote.paymentMethod == PaymentMethodKind.Bolt12 && - updatedQuote.amountPaid > 0 && - updatedQuote.amountIssued >= updatedQuote.amountPaid - ) { - return false - } - - runCatching { gateway.mintTokens(quoteId) } - .fold( - onSuccess = { true }, - onFailure = { error -> - if (isAlreadyIssuedMintError(error)) { - true - } else if ( - updatedQuote.paymentMethod == PaymentMethodKind.Onchain && - updatedQuote.state == MintQuoteState.Pending - ) { - false - } else { - AppLogger.wallet.error("Failed to mint pending quote $quoteId", error) - false - } - }, - ) - } catch (error: Throwable) { - if (!isMissingQuoteError(error)) { - AppLogger.wallet.error("Failed to refresh pending quote $quoteId", error) - } - false - } finally { - mintQuoteSyncsInFlight.remove(quoteId) - } - } - - private suspend fun observePendingOnchainMintQuotes( - transactions: List, - ): List = - transactions.map { transaction -> - if ( - transaction.type != TransactionType.Incoming || - transaction.kind != TransactionKind.Onchain || - transaction.invoice == null - ) { - return@map transaction - } - - val observation = OnchainExplorer.observePayment( - address = transaction.invoice, - mintUrl = transaction.mintUrl, - expectedAmount = transaction.amount, - createdAfterEpochMillis = transaction.dateEpochMillis, - ) - - if (observation != null) { - val key = transaction.quoteId ?: transaction.id - val currentPreimages = walletStore.loadPaymentPreimages() - if (currentPreimages[key] != observation.txid) { - walletStore.savePaymentPreimages(currentPreimages + (key to observation.txid)) - } - transaction.copy( - preimage = observation.txid, - statusNote = observation.statusText, - ) - } else if (transaction.preimage != null) { - transaction.copy(statusNote = transaction.statusNote ?: "Payment detected on-chain") - } else { - transaction - } - } - - private fun rememberMintQuoteTimestamp(quoteId: String) { - val current = walletStore.loadMintQuoteTimestamps() - if (quoteId !in current) { - walletStore.saveMintQuoteTimestamps(current + (quoteId to System.currentTimeMillis())) - } - } - - private fun isMissingQuoteError(error: Throwable): Boolean { - val message = "${error.message.orEmpty()} ${error}".lowercase() - return message.contains("not found") || - message.contains("no stored mint quote") || - message.contains("missing quote") - } - - private fun isAlreadyIssuedMintError(error: Throwable): Boolean { - val message = "${error.message.orEmpty()} ${error}".lowercase() - if ( - message.contains("already being minted") || - message.contains("not issued") || - message.contains("not yet") || - message.contains("unissued") - ) { - return false - } - return message.contains("already issued") || - message.contains("already minted") || - message.contains("quote is issued") || - message.contains("state=issued") || - message.contains("tokens already issued") - } - private fun activeMintFrom(mints: List): MintInfo? { val saved = walletStore.activeMintURL return mints.firstOrNull { it.url == saved } ?: mints.firstOrNull() } private suspend fun ensureMintTracked(url: String): String { - val normalized = normalizeMintUrl(url) + val normalized = mintMetadataFetcher.normalizeMintUrl(url) runCatching { gateway.ensureWallet(normalized) } .onFailure { AppLogger.wallet.error("CDK wallet preparation is not available yet for $normalized", it) } if (walletStore.loadMints().any { it.url == normalized }) return normalized val fetched = runCatching { gateway.fetchMintInfo(normalized) } .getOrNull() - ?: runCatching { fetchRawMintInfo(normalized) }.getOrElse { + ?: runCatching { mintMetadataFetcher.fetchRawMintInfo(normalized) }.getOrElse { MintInfo( url = normalized, name = runCatching { URL(normalized).host }.getOrNull() ?: "Unknown Mint", @@ -797,55 +589,6 @@ class WalletManager( return normalized } - private suspend fun fetchRawMintInfo(url: String): MintInfo = withContext(Dispatchers.IO) { - val connection = (URL("$url/v1/info").openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connectTimeout = 10_000 - readTimeout = 10_000 - } - try { - if (connection.responseCode !in 200..299) throw IllegalStateException("Mint info HTTP ${connection.responseCode}") - val body = connection.inputStream.bufferedReader().use { it.readText() } - val root = Json.parseToJsonElement(body).jsonObject - val name = root["name"]?.jsonPrimitive?.content ?: URL(url).host ?: "Unknown Mint" - val description = root["description"]?.jsonPrimitive?.content - val iconUrl = root["icon_url"]?.jsonPrimitive?.content - val nuts = root["nuts"]?.jsonObject - val nut04 = nuts?.get("4")?.jsonObject - val methods = nut04?.get("methods")?.jsonArray.orEmpty() - val supportsOnchain = methods.any { element -> - element.jsonObject["method"]?.jsonPrimitive?.content?.lowercase() == "onchain" - } - MintInfo( - url = url, - name = name, - description = description, - iconUrl = iconUrl, - supportedMintMethods = listOfNotNull( - PaymentMethodKind.Bolt11, - PaymentMethodKind.Onchain.takeIf { supportsOnchain }, - ), - ) - } finally { - connection.disconnect() - } - } - - private fun normalizeMintUrl(url: String): String { - var normalized = url.trim() - if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) { - normalized = "https://$normalized" - } - return normalized.trimEnd('/') - } - - private fun validateMintUrl(url: String): String? { - val parsed = runCatching { URL(url) }.getOrNull() ?: return "Invalid URL format." - if (parsed.host.isNullOrBlank()) return "Invalid URL format." - if (parsed.protocol != "https") return "Mint URL must use HTTPS for security." - return null - } - private suspend fun deriveNostrKey(mnemonic: String) { runCatching { nostrService.deriveKeypairFromSeed(gateway.mnemonicEntropy(mnemonic)) } .onFailure { AppLogger.wallet.error("Nostr key derivation failed", it) } @@ -888,23 +631,3 @@ class WalletManager( update { copy(needsOnboarding = true, canExitOnboarding = true) } } } - -internal fun WalletTransaction.withStoredMeltMetadata( - preimages: Map, - meltFees: Map, -): WalletTransaction { - val key = quoteId ?: id - return copy( - preimage = preimage ?: preimages[key], - fee = meltFees[key] ?: fee, - ) -} - -internal fun shouldAttemptWalletDatabaseRecovery(error: Throwable): Boolean { - val normalized = (error.message ?: error.toString()).lowercase() - return normalized.contains("sqlite") || - normalized.contains("database") || - normalized.contains("corrupt") || - normalized.contains("malformed") || - normalized.contains("walletdb") -} diff --git a/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletMintMetadataFetcher.kt b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletMintMetadataFetcher.kt new file mode 100644 index 0000000..ef0e0de --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletMintMetadataFetcher.kt @@ -0,0 +1,65 @@ +package org.cashu.wallet.Core + +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.cashu.wallet.Models.MintInfo +import org.cashu.wallet.Models.PaymentMethodKind + +internal class WalletMintMetadataFetcher { + suspend fun fetchRawMintInfo(url: String): MintInfo = withContext(Dispatchers.IO) { + val connection = (URL("$url/v1/info").openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 10_000 + readTimeout = 10_000 + } + try { + if (connection.responseCode !in 200..299) { + throw IllegalStateException("Mint info HTTP ${connection.responseCode}") + } + val body = connection.inputStream.bufferedReader().use { it.readText() } + val root = Json.parseToJsonElement(body).jsonObject + val name = root["name"]?.jsonPrimitive?.content ?: URL(url).host ?: "Unknown Mint" + val description = root["description"]?.jsonPrimitive?.content + val iconUrl = root["icon_url"]?.jsonPrimitive?.content + val nuts = root["nuts"]?.jsonObject + val nut04 = nuts?.get("4")?.jsonObject + val methods = nut04?.get("methods")?.jsonArray.orEmpty() + val supportsOnchain = methods.any { element -> + element.jsonObject["method"]?.jsonPrimitive?.content?.lowercase() == "onchain" + } + MintInfo( + url = url, + name = name, + description = description, + iconUrl = iconUrl, + supportedMintMethods = listOfNotNull( + PaymentMethodKind.Bolt11, + PaymentMethodKind.Onchain.takeIf { supportsOnchain }, + ), + ) + } finally { + connection.disconnect() + } + } + + fun normalizeMintUrl(url: String): String { + var normalized = url.trim() + if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) { + normalized = "https://$normalized" + } + return normalized.trimEnd('/') + } + + fun validateMintUrl(url: String): String? { + val parsed = runCatching { URL(url) }.getOrNull() ?: return "Invalid URL format." + if (parsed.host.isNullOrBlank()) return "Invalid URL format." + if (parsed.protocol != "https") return "Mint URL must use HTTPS for security." + return null + } +} diff --git a/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletMintQuoteSyncService.kt b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletMintQuoteSyncService.kt new file mode 100644 index 0000000..ac2b9e6 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletMintQuoteSyncService.kt @@ -0,0 +1,89 @@ +package org.cashu.wallet.Core + +import org.cashu.wallet.Core.CDK.CdkWalletGateway +import org.cashu.wallet.Models.MintQuoteState +import org.cashu.wallet.Models.PaymentMethodKind + +internal class WalletMintQuoteSyncService( + private val gateway: CdkWalletGateway, + private val walletStore: WalletStore, +) { + private val mintQuoteSyncsInFlight = mutableSetOf() + + suspend fun syncPendingMintQuote( + quoteId: String, + allowPendingOnchainMintAttempt: Boolean, + ): Boolean { + if (!mintQuoteSyncsInFlight.add(quoteId)) return false + return try { + val updatedQuote = gateway.checkMintQuote(quoteId).also { rememberMintQuoteTimestamp(it.id) } + val shouldAttemptMint = updatedQuote.state == MintQuoteState.Paid || + updatedQuote.state == MintQuoteState.Issued || + (allowPendingOnchainMintAttempt && updatedQuote.paymentMethod == PaymentMethodKind.Onchain) + if (!shouldAttemptMint) return false + + if (updatedQuote.paymentMethod == PaymentMethodKind.Bolt12 && + updatedQuote.amountPaid > 0 && + updatedQuote.amountIssued >= updatedQuote.amountPaid + ) { + return false + } + + runCatching { gateway.mintTokens(quoteId) } + .fold( + onSuccess = { true }, + onFailure = { error -> + if (isAlreadyIssuedMintError(error)) { + true + } else if ( + updatedQuote.paymentMethod == PaymentMethodKind.Onchain && + updatedQuote.state == MintQuoteState.Pending + ) { + false + } else { + AppLogger.wallet.error("Failed to mint pending quote $quoteId", error) + false + } + }, + ) + } catch (error: Throwable) { + if (!isMissingQuoteError(error)) { + AppLogger.wallet.error("Failed to refresh pending quote $quoteId", error) + } + false + } finally { + mintQuoteSyncsInFlight.remove(quoteId) + } + } + + fun rememberMintQuoteTimestamp(quoteId: String) { + val current = walletStore.loadMintQuoteTimestamps() + if (quoteId !in current) { + walletStore.saveMintQuoteTimestamps(current + (quoteId to System.currentTimeMillis())) + } + } + + fun isAlreadyIssuedMintError(error: Throwable): Boolean { + val message = "${error.message.orEmpty()} ${error}".lowercase() + if ( + message.contains("already being minted") || + message.contains("not issued") || + message.contains("not yet") || + message.contains("unissued") + ) { + return false + } + return message.contains("already issued") || + message.contains("already minted") || + message.contains("quote is issued") || + message.contains("state=issued") || + message.contains("tokens already issued") + } + + private fun isMissingQuoteError(error: Throwable): Boolean { + val message = "${error.message.orEmpty()} ${error}".lowercase() + return message.contains("not found") || + message.contains("no stored mint quote") || + message.contains("missing quote") + } +} diff --git a/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletState.kt b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletState.kt new file mode 100644 index 0000000..d43841b --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletState.kt @@ -0,0 +1,25 @@ +package org.cashu.wallet.Core + +import org.cashu.wallet.Models.ClaimedToken +import org.cashu.wallet.Models.MintInfo +import org.cashu.wallet.Models.PendingReceiveToken +import org.cashu.wallet.Models.PendingToken +import org.cashu.wallet.Models.WalletTransaction + +data class WalletState( + val balance: Long = 0, + val pendingBalance: Long = 0, + val isInitialized: Boolean = false, + val needsOnboarding: Boolean = true, + val canExitOnboarding: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val activeUnit: String = "sat", + val mints: List = emptyList(), + val activeMint: MintInfo? = null, + val transactions: List = emptyList(), + val pendingTokens: List = emptyList(), + val pendingReceiveTokens: List = emptyList(), + val claimedTokens: List = emptyList(), + val transactionUpdateVersion: Long = 0, +) diff --git a/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletTransactionLoader.kt b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletTransactionLoader.kt new file mode 100644 index 0000000..e057f18 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Core/Wallet/WalletTransactionLoader.kt @@ -0,0 +1,182 @@ +package org.cashu.wallet.Core + +import org.cashu.wallet.Core.CDK.CdkWalletGateway +import org.cashu.wallet.Models.ClaimedToken +import org.cashu.wallet.Models.MeltPaymentResult +import org.cashu.wallet.Models.PaymentMethodKind +import org.cashu.wallet.Models.PendingReceiveToken +import org.cashu.wallet.Models.PendingToken +import org.cashu.wallet.Models.TransactionKind +import org.cashu.wallet.Models.TransactionStatus +import org.cashu.wallet.Models.TransactionType +import org.cashu.wallet.Models.WalletTransaction + +internal data class WalletTransactionLoadResult( + val transactions: List, + val pendingTokens: List, + val pendingReceiveTokens: List, + val claimedTokens: List, +) + +internal data class TokenHistoryMutation( + val pendingTokens: List, + val claimedTokens: List, +) + +internal class WalletTransactionLoader( + private val walletStore: WalletStore, + private val gateway: CdkWalletGateway, +) { + suspend fun load(mintUrls: List): WalletTransactionLoadResult { + val trackedMintUrls = mintUrls.toSet() + val preimages = walletStore.loadPaymentPreimages() + val meltFees = walletStore.loadMeltQuoteFees() + val pendingTokens = walletStore.loadPendingTokens() + val pendingReceiveTokens = walletStore.loadPendingReceiveTokens() + val claimedTokens = walletStore.loadClaimedTokens() + val remote = runCatching { gateway.listTransactions(mintUrls) }.getOrDefault(emptyList()) + .map { it.withStoredMeltMetadata(preimages, meltFees) } + val completedQuoteIds = remote.mapNotNull { it.quoteId }.toSet() + val mintQuoteTimestamps = walletStore.loadMintQuoteTimestamps().toMutableMap() + val pendingQuoteTransactions = observePendingOnchainMintQuotes( + runCatching { gateway.listUnissuedMintQuotes() } + .getOrDefault(emptyList()) + .let { quotes -> + pendingMintQuoteTransactions( + quotes = quotes, + trackedMintUrls = trackedMintUrls, + completedQuoteIds = completedQuoteIds, + timestamps = mintQuoteTimestamps, + nowEpochMillis = System.currentTimeMillis(), + ) + } + .map { it.withStoredMeltMetadata(preimages, meltFees) }, + ) + val storedMeltTransactions = runCatching { gateway.listMeltQuotes() } + .getOrDefault(emptyList()) + .let { quotes -> + storedMeltQuoteTransactions( + quotes = quotes, + trackedMintUrls = trackedMintUrls, + completedQuoteIds = completedQuoteIds, + timestamps = mintQuoteTimestamps, + nowEpochMillis = System.currentTimeMillis(), + preimages = preimages, + fees = meltFees, + ) + } + val tokenTransactions = pendingSentTokenTransactions(pendingTokens) + + pendingReceiveTokenTransactions(pendingReceiveTokens) + + claimedTokenTransactions(claimedTokens) + val cached = walletStore.loadTransactions() + .filterNot { it.isPendingToken } + .map { it.withStoredMeltMetadata(preimages, meltFees) } + val merged = (remote + pendingQuoteTransactions + storedMeltTransactions + tokenTransactions + cached) + .distinctBy { "${it.mintUrl.orEmpty()}|${it.quoteId ?: it.id}" } + .sortedByDescending { it.dateEpochMillis } + walletStore.saveTransactions(merged) + walletStore.saveMintQuoteTimestamps(pruneMintQuoteTimestamps(merged, mintQuoteTimestamps)) + return WalletTransactionLoadResult( + transactions = merged, + pendingTokens = pendingTokens, + pendingReceiveTokens = pendingReceiveTokens, + claimedTokens = claimedTokens, + ) + } + + fun markPendingTokenClaimed(pendingToken: PendingToken): TokenHistoryMutation { + val pending = walletStore.loadPendingTokens().filterNot { it.tokenId == pendingToken.tokenId } + val claimedToken = ClaimedToken( + tokenId = pendingToken.tokenId, + token = pendingToken.token, + amount = pendingToken.amount, + fee = pendingToken.fee, + dateEpochMillis = pendingToken.dateEpochMillis, + mintUrl = pendingToken.mintUrl, + memo = pendingToken.memo, + claimedDateEpochMillis = System.currentTimeMillis(), + ) + val claimed = walletStore.loadClaimedTokens() + .filterNot { it.tokenId == pendingToken.tokenId } + claimedToken + walletStore.savePendingTokens(pending) + walletStore.saveClaimedTokens(claimed) + return TokenHistoryMutation(pendingTokens = pending, claimedTokens = claimed) + } + + fun saveMeltPaymentMetadata(quoteId: String, result: MeltPaymentResult) { + result.preimage?.let { preimage -> + walletStore.savePaymentPreimages(walletStore.loadPaymentPreimages() + (quoteId to preimage)) + } + walletStore.saveMeltQuoteFees(walletStore.loadMeltQuoteFees() + (quoteId to result.feePaid)) + + val current = walletStore.loadTransactions() + val existing = current.firstOrNull { it.quoteId == quoteId || it.id == quoteId } + val transaction = WalletTransaction( + id = existing?.id ?: quoteId, + amount = result.amount, + type = TransactionType.Outgoing, + kind = when (result.paymentMethod) { + PaymentMethodKind.Onchain -> TransactionKind.Onchain + else -> TransactionKind.Lightning + }, + dateEpochMillis = existing?.dateEpochMillis ?: System.currentTimeMillis(), + memo = existing?.memo, + status = TransactionStatus.Completed, + mintUrl = result.mintUrl, + preimage = result.preimage ?: existing?.preimage, + invoice = result.request ?: existing?.invoice, + fee = result.feePaid, + quoteId = quoteId, + ) + walletStore.saveTransactions( + listOf(transaction) + current.filterNot { it.id == transaction.id || it.quoteId == quoteId }, + ) + } + + private suspend fun observePendingOnchainMintQuotes( + transactions: List, + ): List = + transactions.map { transaction -> + if ( + transaction.type != TransactionType.Incoming || + transaction.kind != TransactionKind.Onchain || + transaction.invoice == null + ) { + return@map transaction + } + + val observation = OnchainExplorer.observePayment( + address = transaction.invoice, + mintUrl = transaction.mintUrl, + expectedAmount = transaction.amount, + createdAfterEpochMillis = transaction.dateEpochMillis, + ) + + if (observation != null) { + val key = transaction.quoteId ?: transaction.id + val currentPreimages = walletStore.loadPaymentPreimages() + if (currentPreimages[key] != observation.txid) { + walletStore.savePaymentPreimages(currentPreimages + (key to observation.txid)) + } + transaction.copy( + preimage = observation.txid, + statusNote = observation.statusText, + ) + } else if (transaction.preimage != null) { + transaction.copy(statusNote = transaction.statusNote ?: "Payment detected on-chain") + } else { + transaction + } + } +} + +internal fun WalletTransaction.withStoredMeltMetadata( + preimages: Map, + meltFees: Map, +): WalletTransaction { + val key = quoteId ?: id + return copy( + preimage = preimage ?: preimages[key], + fee = meltFees[key] ?: fee, + ) +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Mints/MintInfo.kt b/android/app/src/main/java/org/cashu/wallet/Models/Mints/MintInfo.kt new file mode 100644 index 0000000..d982d5d --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Mints/MintInfo.kt @@ -0,0 +1,20 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +data class MintInfo( + val url: String, + val name: String = "Unknown Mint", + val description: String? = null, + val isActive: Boolean = true, + val balance: Long = 0, + val iconUrl: String? = null, + val units: List = listOf("sat"), + val supportedMintMethods: List = listOf(PaymentMethodKind.Bolt11), + val supportedMeltMethods: List = listOf(PaymentMethodKind.Bolt11), + val onchainMintConfirmations: Int? = null, + val lastUpdatedEpochMillis: Long = System.currentTimeMillis(), +) { + val id: String get() = url +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Models.kt b/android/app/src/main/java/org/cashu/wallet/Models/Models.kt deleted file mode 100644 index 85d2cb9..0000000 --- a/android/app/src/main/java/org/cashu/wallet/Models/Models.kt +++ /dev/null @@ -1,355 +0,0 @@ -package org.cashu.wallet.Models - -import java.util.UUID -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import org.cashu.wallet.Core.TokenParser - -@Serializable -enum class PaymentMethodKind { - @SerialName("bolt11") - Bolt11, - - @SerialName("bolt12") - Bolt12, - - @SerialName("onchain") - Onchain; - - val rawValue: String - get() = when (this) { - Bolt11 -> "bolt11" - Bolt12 -> "bolt12" - Onchain -> "onchain" - } - - val displayName: String - get() = when (this) { - Bolt11 -> "BOLT11" - Bolt12 -> "BOLT12" - Onchain -> "On-chain" - } - - val symbol: String - get() = when (this) { - Bolt11 -> "\u26A1" - Bolt12 -> "\uD83D\uDD17" - Onchain -> "\u20BF" - } - - val requestDisplayName: String - get() = when (this) { - Bolt11 -> "Invoice" - Bolt12 -> "Offer" - Onchain -> "Address" - } - - val sortOrder: Int - get() = when (this) { - Bolt11 -> 0 - Bolt12 -> 1 - Onchain -> 2 - } - - val requiresMintAmount: Boolean - get() = this != Bolt12 - - val supportsOptionalMintAmount: Boolean - get() = this == Bolt12 - - companion object { - fun fromRaw(value: String?): PaymentMethodKind? = when (value?.lowercase()) { - "bolt11" -> Bolt11 - "bolt12" -> Bolt12 - "onchain" -> Onchain - else -> null - } - } -} - -@Serializable -data class OnchainPaymentObservation( - val txid: String, - val amount: Long, - val confirmed: Boolean, - val confirmations: Int? = null, -) { - val statusText: String - get() = when { - confirmations != null && confirmations > 0 -> { - val suffix = if (confirmations == 1) "" else "s" - "Payment confirmed on-chain ($confirmations confirmation$suffix)" - } - confirmed -> "Payment detected on-chain" - else -> "Payment seen in mempool" - } -} - -@Serializable -data class MintInfo( - val url: String, - val name: String = "Unknown Mint", - val description: String? = null, - val isActive: Boolean = true, - val balance: Long = 0, - val iconUrl: String? = null, - val units: List = listOf("sat"), - val supportedMintMethods: List = listOf(PaymentMethodKind.Bolt11), - val supportedMeltMethods: List = listOf(PaymentMethodKind.Bolt11), - val onchainMintConfirmations: Int? = null, - val lastUpdatedEpochMillis: Long = System.currentTimeMillis(), -) { - val id: String get() = url -} - -@Serializable -enum class MintQuoteState { - Unpaid, - Pending, - Paid, - Issued, - Failed, - Unknown, -} - -@Serializable -data class MintQuoteInfo( - val id: String, - val request: String, - val amount: Long?, - val paymentMethod: PaymentMethodKind, - val state: MintQuoteState, - val expiryEpochSeconds: Long?, - val mintUrl: String? = null, - val amountPaid: Long = 0, - val amountIssued: Long = 0, -) { - val isExpired: Boolean - get() = expiryEpochSeconds != null && - expiryEpochSeconds > 0 && - System.currentTimeMillis() / 1000 > expiryEpochSeconds -} - -@Serializable -enum class MeltQuoteState { - Unpaid, - Pending, - Paid, - Failed, - Unknown, -} - -@Serializable -data class MeltQuoteInfo( - val id: String, - val mintUrl: String, - val amount: Long, - val feeReserve: Long, - val paymentMethod: PaymentMethodKind, - val state: MeltQuoteState, - val expiryEpochSeconds: Long?, - val request: String? = null, - val paymentProof: String? = null, -) { - val totalAmount: Long get() = amount + feeReserve - val isExpired: Boolean - get() = expiryEpochSeconds != null && - expiryEpochSeconds > 0 && - System.currentTimeMillis() / 1000 > expiryEpochSeconds -} - -@Serializable -data class MeltPaymentResult( - val preimage: String?, - val amount: Long, - val feePaid: Long, - val mintUrl: String, - val paymentMethod: PaymentMethodKind? = null, - val request: String? = null, -) - -@Serializable -data class WalletTransaction( - val id: String, - val amount: Long, - val type: TransactionType, - val kind: TransactionKind, - val dateEpochMillis: Long, - val memo: String? = null, - val status: TransactionStatus, - val statusNote: String? = null, - val mintUrl: String? = null, - val preimage: String? = null, - val token: String? = null, - val invoice: String? = null, - val fee: Long = 0, - val isPendingToken: Boolean = false, - val quoteId: String? = null, - val cashuRequestId: String? = null, -) { - val displayStatusText: String - get() = if (status == TransactionStatus.Pending) statusNote ?: status.displayText else status.displayText -} - -@Serializable -enum class TransactionType { - Incoming, - Outgoing, -} - -@Serializable -enum class TransactionKind { - Ecash, - Lightning, - Onchain; - - val displayName: String - get() = when (this) { - Ecash -> "Ecash" - Lightning -> "Lightning" - Onchain -> "On-chain" - } -} - -@Serializable -enum class TransactionStatus { - Pending, - Completed, - Failed; - - val displayText: String - get() = when (this) { - Pending -> "Pending" - Completed -> "Completed" - Failed -> "Failed" - } -} - -@Serializable -data class SendTokenResult( - val token: String, - val fee: Long, -) - -@Serializable -data class PendingToken( - val tokenId: String, - val token: String, - val amount: Long, - val fee: Long, - val dateEpochMillis: Long, - val mintUrl: String, - val memo: String? = null, -) { - val id: String get() = tokenId -} - -@Serializable -data class PendingReceiveToken( - val tokenId: String, - val token: String, - val amount: Long, - val dateEpochMillis: Long, - val mintUrl: String, -) { - val id: String get() = tokenId -} - -@Serializable -data class CashuRequestPayment( - val transactionId: String, - val amount: Long, - val receivedAtEpochMillis: Long, -) - -@Serializable -data class CashuRequest( - val id: String = newId(), - val encoded: String, - val amount: Long? = null, - val unit: String = "sat", - val mints: List = emptyList(), - val memo: String? = null, - val createdAtEpochMillis: Long = System.currentTimeMillis(), - val receivedPayments: List = emptyList(), - val receivedPaymentIds: List = emptyList(), -) { - val totalReceived: Long get() = receivedPayments.sumOf { it.amount } - - fun withLegacyPaymentFallback(): CashuRequest { - if (receivedPayments.isNotEmpty() || receivedPaymentIds.isEmpty()) return this - return copy( - receivedPayments = receivedPaymentIds.map { id -> - CashuRequestPayment( - transactionId = id, - amount = 0, - receivedAtEpochMillis = createdAtEpochMillis, - ) - }, - receivedPaymentIds = emptyList(), - ) - } - - companion object { - fun newId(): String = UUID.randomUUID().toString().substringBefore("-") - } -} - -@Serializable -data class ClaimedToken( - val tokenId: String, - val token: String, - val amount: Long, - val fee: Long, - val dateEpochMillis: Long, - val mintUrl: String, - val memo: String? = null, - val claimedDateEpochMillis: Long, -) { - val id: String get() = tokenId -} - -@Serializable -data class RestoreMintResult( - val mintUrl: String, - val mintName: String, - val spent: Long, - val unspent: Long, - val pending: Long, -) { - val id: String get() = mintUrl - val totalRecovered: Long get() = unspent + pending -} - -@Serializable -data class TokenInfo( - val amount: Long, - val mint: String, - val unit: String, - val memo: String?, - val proofCount: Int, -) { - companion object { - fun parse(tokenString: String): TokenInfo? = TokenParser.tokenInfo(tokenString) - } -} - -@Serializable -data class NwcConnection( - val id: String, - val name: String, - val walletPublicKey: String, - val connectionPublicKey: String, - val allowanceSats: Long?, - val createdAtEpochMillis: Long = System.currentTimeMillis(), -) - -@Serializable -data class P2PKKeyInfo( - val id: String, - val publicKey: String, - val label: String, - val createdAtEpochMillis: Long = System.currentTimeMillis(), - val used: Boolean = false, - val usedCount: Int = 0, -) diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Payments/OnchainPaymentObservation.kt b/android/app/src/main/java/org/cashu/wallet/Models/Payments/OnchainPaymentObservation.kt new file mode 100644 index 0000000..d849ec7 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Payments/OnchainPaymentObservation.kt @@ -0,0 +1,21 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +data class OnchainPaymentObservation( + val txid: String, + val amount: Long, + val confirmed: Boolean, + val confirmations: Int? = null, +) { + val statusText: String + get() = when { + confirmations != null && confirmations > 0 -> { + val suffix = if (confirmations == 1) "" else "s" + "Payment confirmed on-chain ($confirmations confirmation$suffix)" + } + confirmed -> "Payment detected on-chain" + else -> "Payment seen in mempool" + } +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Payments/PaymentMethodKind.kt b/android/app/src/main/java/org/cashu/wallet/Models/Payments/PaymentMethodKind.kt new file mode 100644 index 0000000..13d0e6f --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Payments/PaymentMethodKind.kt @@ -0,0 +1,66 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class PaymentMethodKind { + @SerialName("bolt11") + Bolt11, + + @SerialName("bolt12") + Bolt12, + + @SerialName("onchain") + Onchain; + + val rawValue: String + get() = when (this) { + Bolt11 -> "bolt11" + Bolt12 -> "bolt12" + Onchain -> "onchain" + } + + val displayName: String + get() = when (this) { + Bolt11 -> "BOLT11" + Bolt12 -> "BOLT12" + Onchain -> "On-chain" + } + + val symbol: String + get() = when (this) { + Bolt11 -> "\u26A1" + Bolt12 -> "\uD83D\uDD17" + Onchain -> "\u20BF" + } + + val requestDisplayName: String + get() = when (this) { + Bolt11 -> "Invoice" + Bolt12 -> "Offer" + Onchain -> "Address" + } + + val sortOrder: Int + get() = when (this) { + Bolt11 -> 0 + Bolt12 -> 1 + Onchain -> 2 + } + + val requiresMintAmount: Boolean + get() = this != Bolt12 + + val supportsOptionalMintAmount: Boolean + get() = this == Bolt12 + + companion object { + fun fromRaw(value: String?): PaymentMethodKind? = when (value?.lowercase()) { + "bolt11" -> Bolt11 + "bolt12" -> Bolt12 + "onchain" -> Onchain + else -> null + } + } +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Quotes/QuoteModels.kt b/android/app/src/main/java/org/cashu/wallet/Models/Quotes/QuoteModels.kt new file mode 100644 index 0000000..663cdad --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Quotes/QuoteModels.kt @@ -0,0 +1,69 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +enum class MintQuoteState { + Unpaid, + Pending, + Paid, + Issued, + Failed, + Unknown, +} + +@Serializable +data class MintQuoteInfo( + val id: String, + val request: String, + val amount: Long?, + val paymentMethod: PaymentMethodKind, + val state: MintQuoteState, + val expiryEpochSeconds: Long?, + val mintUrl: String? = null, + val amountPaid: Long = 0, + val amountIssued: Long = 0, +) { + val isExpired: Boolean + get() = expiryEpochSeconds != null && + expiryEpochSeconds > 0 && + System.currentTimeMillis() / 1000 > expiryEpochSeconds +} + +@Serializable +enum class MeltQuoteState { + Unpaid, + Pending, + Paid, + Failed, + Unknown, +} + +@Serializable +data class MeltQuoteInfo( + val id: String, + val mintUrl: String, + val amount: Long, + val feeReserve: Long, + val paymentMethod: PaymentMethodKind, + val state: MeltQuoteState, + val expiryEpochSeconds: Long?, + val request: String? = null, + val paymentProof: String? = null, +) { + val totalAmount: Long get() = amount + feeReserve + val isExpired: Boolean + get() = expiryEpochSeconds != null && + expiryEpochSeconds > 0 && + System.currentTimeMillis() / 1000 > expiryEpochSeconds +} + +@Serializable +data class MeltPaymentResult( + val preimage: String?, + val amount: Long, + val feePaid: Long, + val mintUrl: String, + val paymentMethod: PaymentMethodKind? = null, + val request: String? = null, +) diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Requests/CashuRequest.kt b/android/app/src/main/java/org/cashu/wallet/Models/Requests/CashuRequest.kt new file mode 100644 index 0000000..669f1c6 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Requests/CashuRequest.kt @@ -0,0 +1,44 @@ +package org.cashu.wallet.Models + +import java.util.UUID +import kotlinx.serialization.Serializable + +@Serializable +data class CashuRequestPayment( + val transactionId: String, + val amount: Long, + val receivedAtEpochMillis: Long, +) + +@Serializable +data class CashuRequest( + val id: String = newId(), + val encoded: String, + val amount: Long? = null, + val unit: String = "sat", + val mints: List = emptyList(), + val memo: String? = null, + val createdAtEpochMillis: Long = System.currentTimeMillis(), + val receivedPayments: List = emptyList(), + val receivedPaymentIds: List = emptyList(), +) { + val totalReceived: Long get() = receivedPayments.sumOf { it.amount } + + fun withLegacyPaymentFallback(): CashuRequest { + if (receivedPayments.isNotEmpty() || receivedPaymentIds.isEmpty()) return this + return copy( + receivedPayments = receivedPaymentIds.map { id -> + CashuRequestPayment( + transactionId = id, + amount = 0, + receivedAtEpochMillis = createdAtEpochMillis, + ) + }, + receivedPaymentIds = emptyList(), + ) + } + + companion object { + fun newId(): String = UUID.randomUUID().toString().substringBefore("-") + } +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Settings/NwcConnection.kt b/android/app/src/main/java/org/cashu/wallet/Models/Settings/NwcConnection.kt new file mode 100644 index 0000000..56060fa --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Settings/NwcConnection.kt @@ -0,0 +1,13 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +data class NwcConnection( + val id: String, + val name: String, + val walletPublicKey: String, + val connectionPublicKey: String, + val allowanceSats: Long?, + val createdAtEpochMillis: Long = System.currentTimeMillis(), +) diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Settings/P2PKKeyInfo.kt b/android/app/src/main/java/org/cashu/wallet/Models/Settings/P2PKKeyInfo.kt new file mode 100644 index 0000000..94781af --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Settings/P2PKKeyInfo.kt @@ -0,0 +1,13 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +data class P2PKKeyInfo( + val id: String, + val publicKey: String, + val label: String, + val createdAtEpochMillis: Long = System.currentTimeMillis(), + val used: Boolean = false, + val usedCount: Int = 0, +) diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Tokens/TokenInfo.kt b/android/app/src/main/java/org/cashu/wallet/Models/Tokens/TokenInfo.kt new file mode 100644 index 0000000..b57c6f4 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Tokens/TokenInfo.kt @@ -0,0 +1,17 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable +import org.cashu.wallet.Core.TokenParser + +@Serializable +data class TokenInfo( + val amount: Long, + val mint: String, + val unit: String, + val memo: String?, + val proofCount: Int, +) { + companion object { + fun parse(tokenString: String): TokenInfo? = TokenParser.tokenInfo(tokenString) + } +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Tokens/TokenTransferModels.kt b/android/app/src/main/java/org/cashu/wallet/Models/Tokens/TokenTransferModels.kt new file mode 100644 index 0000000..2b19f71 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Tokens/TokenTransferModels.kt @@ -0,0 +1,47 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +data class SendTokenResult( + val token: String, + val fee: Long, +) + +@Serializable +data class PendingToken( + val tokenId: String, + val token: String, + val amount: Long, + val fee: Long, + val dateEpochMillis: Long, + val mintUrl: String, + val memo: String? = null, +) { + val id: String get() = tokenId +} + +@Serializable +data class PendingReceiveToken( + val tokenId: String, + val token: String, + val amount: Long, + val dateEpochMillis: Long, + val mintUrl: String, +) { + val id: String get() = tokenId +} + +@Serializable +data class ClaimedToken( + val tokenId: String, + val token: String, + val amount: Long, + val fee: Long, + val dateEpochMillis: Long, + val mintUrl: String, + val memo: String? = null, + val claimedDateEpochMillis: Long, +) { + val id: String get() = tokenId +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/Transactions/WalletTransaction.kt b/android/app/src/main/java/org/cashu/wallet/Models/Transactions/WalletTransaction.kt new file mode 100644 index 0000000..ab30913 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/Transactions/WalletTransaction.kt @@ -0,0 +1,60 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +data class WalletTransaction( + val id: String, + val amount: Long, + val type: TransactionType, + val kind: TransactionKind, + val dateEpochMillis: Long, + val memo: String? = null, + val status: TransactionStatus, + val statusNote: String? = null, + val mintUrl: String? = null, + val preimage: String? = null, + val token: String? = null, + val invoice: String? = null, + val fee: Long = 0, + val isPendingToken: Boolean = false, + val quoteId: String? = null, + val cashuRequestId: String? = null, +) { + val displayStatusText: String + get() = if (status == TransactionStatus.Pending) statusNote ?: status.displayText else status.displayText +} + +@Serializable +enum class TransactionType { + Incoming, + Outgoing, +} + +@Serializable +enum class TransactionKind { + Ecash, + Lightning, + Onchain; + + val displayName: String + get() = when (this) { + Ecash -> "Ecash" + Lightning -> "Lightning" + Onchain -> "On-chain" + } +} + +@Serializable +enum class TransactionStatus { + Pending, + Completed, + Failed; + + val displayText: String + get() = when (this) { + Pending -> "Pending" + Completed -> "Completed" + Failed -> "Failed" + } +} diff --git a/android/app/src/main/java/org/cashu/wallet/Models/WalletSupport/RestoreMintResult.kt b/android/app/src/main/java/org/cashu/wallet/Models/WalletSupport/RestoreMintResult.kt new file mode 100644 index 0000000..20b5006 --- /dev/null +++ b/android/app/src/main/java/org/cashu/wallet/Models/WalletSupport/RestoreMintResult.kt @@ -0,0 +1,15 @@ +package org.cashu.wallet.Models + +import kotlinx.serialization.Serializable + +@Serializable +data class RestoreMintResult( + val mintUrl: String, + val mintName: String, + val spent: Long, + val unspent: Long, + val pending: Long, +) { + val id: String get() = mintUrl + val totalRecovered: Long get() = unspent + pending +}