Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<String>()

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")
}
}
Original file line number Diff line number Diff line change
@@ -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<MintInfo> = emptyList(),
val activeMint: MintInfo? = null,
val transactions: List<WalletTransaction> = emptyList(),
val pendingTokens: List<PendingToken> = emptyList(),
val pendingReceiveTokens: List<PendingReceiveToken> = emptyList(),
val claimedTokens: List<ClaimedToken> = emptyList(),
val transactionUpdateVersion: Long = 0,
)
Original file line number Diff line number Diff line change
@@ -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<WalletTransaction>,
val pendingTokens: List<PendingToken>,
val pendingReceiveTokens: List<PendingReceiveToken>,
val claimedTokens: List<ClaimedToken>,
)

internal data class TokenHistoryMutation(
val pendingTokens: List<PendingToken>,
val claimedTokens: List<ClaimedToken>,
)

internal class WalletTransactionLoader(
private val walletStore: WalletStore,
private val gateway: CdkWalletGateway,
) {
suspend fun load(mintUrls: List<String>): 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<WalletTransaction>,
): List<WalletTransaction> =
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<String, String>,
meltFees: Map<String, Long>,
): WalletTransaction {
val key = quoteId ?: id
return copy(
preimage = preimage ?: preimages[key],
fee = meltFees[key] ?: fee,
)
}
Original file line number Diff line number Diff line change
@@ -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<String> = listOf("sat"),
val supportedMintMethods: List<PaymentMethodKind> = listOf(PaymentMethodKind.Bolt11),
val supportedMeltMethods: List<PaymentMethodKind> = listOf(PaymentMethodKind.Bolt11),
val onchainMintConfirmations: Int? = null,
val lastUpdatedEpochMillis: Long = System.currentTimeMillis(),
) {
val id: String get() = url
}
Loading
Loading