diff --git a/.github/workflows/btcpay-integration.yml b/.github/workflows/btcpay-integration.yml new file mode 100644 index 00000000..d38f2e2c --- /dev/null +++ b/.github/workflows/btcpay-integration.yml @@ -0,0 +1,44 @@ +name: BTCPay Integration Tests + +on: + push: + branches: [ "main", "master", "feat/*", "fix/*" ] + pull_request: + branches: [ "main", "master" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Start BTCPay Server + working-directory: integration-tests/btcpay + run: docker compose up -d + + - name: Provision BTCPay Server + working-directory: integration-tests/btcpay + run: | + chmod +x provision.sh + ./provision.sh + cp btcpay_env.properties ../../btcpay_env.properties + + - name: Run Integration Tests + run: | + chmod +x gradlew + ./gradlew testDebugUnitTest --tests "com.electricdreams.numo.core.payment.impl.BtcPayPaymentServiceIntegrationTest.*" + + - name: Cleanup + if: always() + working-directory: integration-tests/btcpay + run: docker compose down -v \ No newline at end of file diff --git a/.gitignore b/.gitignore index d7d0fce3..f2bc8f05 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ local.properties .aider* .vscode -release \ No newline at end of file +release +btcpay_env.properties diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b984b0c7..669ba93f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -494,6 +494,17 @@ android:value=".feature.settings.SettingsActivity" /> + + + + + btcPayPaymentId = payment.paymentId + + val hasCashu = !payment.cashuPR.isNullOrBlank() + val hasLightning = !payment.bolt11.isNullOrBlank() + + if (!hasCashu && !hasLightning) { + Log.e(TAG, "BTCPay returned no cashuPR and no bolt11 — cannot display payment") + cashuLoadingSpinner.visibility = View.GONE + handlePaymentError("BTCPay returned no payment methods") + return@onSuccess + } + + // Show Cashu QR (cashuPR from BTCNutServer) + if (hasCashu) { + btcPayCashuPR = payment.cashuPR + try { + val qrBitmap = QrCodeGenerator.generate(payment.cashuPR!!, 512) + cashuQrImageView.setImageBitmap(qrBitmap) + cashuQrImageView.visibility = View.VISIBLE + cashuLogoCard.visibility = View.VISIBLE + } catch (e: Exception) { + Log.e(TAG, "Error generating BTCPay Cashu QR: ${e.message}", e) + } + cashuLoadingSpinner.visibility = View.GONE + + // Also use cashuPR for HCE + hcePaymentRequest = CashuPaymentHelper.stripTransports(payment.cashuPR!!) ?: payment.cashuPR + if (NdefHostCardEmulationService.isHceAvailable(this@PaymentRequestActivity)) { + val serviceIntent = Intent(this@PaymentRequestActivity, NdefHostCardEmulationService::class.java) + startService(serviceIntent) + setupNdefPayment() + } + } else { + // No cashuPR — disable Cashu tab and switch to Lightning + cashuLoadingSpinner.visibility = View.GONE + tabManager.disableTab(PaymentTabManager.Tab.CASHU) + } + + // Show Lightning QR (may already be in the response, or fetch in background) + if (hasLightning) { + showBtcPayLightningQr(payment.bolt11!!) + } else { + // createPayment() broke early on cashuPR — fetch bolt11 in background + fetchBtcPayLightningInBackground(payment.paymentId) + } + + statusText.text = getString(R.string.payment_request_status_waiting_for_payment) + + // Start polling BTCPay for payment status + startBtcPayPolling(payment.paymentId) + }.onFailure { error -> + Log.e(TAG, "BTCPay createPayment failed: ${error.message}", error) + cashuLoadingSpinner.visibility = View.GONE + statusText.text = getString(R.string.payment_request_status_error_generic, error.message ?: "Unknown error") + } + } + } + + private fun showBtcPayLightningQr(bolt11: String) { + lightningInvoice = bolt11 + try { + val qrBitmap = QrCodeGenerator.generate(bolt11, 512) + lightningQrImageView.setImageBitmap(qrBitmap) + lightningLoadingSpinner.visibility = View.GONE + lightningLogoCard.visibility = View.VISIBLE + } catch (e: Exception) { + Log.e(TAG, "Error generating BTCPay Lightning QR: ${e.message}", e) + lightningLoadingSpinner.visibility = View.GONE + } + lightningStarted = true + } + + private fun fetchBtcPayLightningInBackground(invoiceId: String) { + val btcPay = paymentService as? BTCPayPaymentService ?: return + uiScope.launch { + for (attempt in 1..10) { + delay(1500) + if (hasTerminalOutcome) break + val bolt11 = btcPay.fetchLightningInvoice(invoiceId) + if (bolt11 != null) { + Log.d(TAG, "Got bolt11 in background after $attempt attempt(s)") + showBtcPayLightningQr(bolt11) + break + } + Log.d(TAG, "Background bolt11 fetch attempt $attempt — not ready yet") + } + } + } + + /** + * Local (CDK) mode: the original flow – NDEF, Nostr, and Lightning tab. + */ + private fun initializeLocalPaymentRequest() { // Get allowed mints val mintManager = MintManager.getInstance(this) val allowedMints = mintManager.getAllowedMints() @@ -516,6 +650,49 @@ class PaymentRequestActivity : AppCompatActivity() { // Lightning flow is now also started immediately (see startLightningMintFlow() call above) } + /** + * Poll BTCPay invoice status every 2 seconds until terminal state. + */ + private fun startBtcPayPolling(paymentId: String) { + btcPayPollingActive = true + uiScope.launch { + while (btcPayPollingActive && !hasTerminalOutcome) { + delay(2000) + if (!btcPayPollingActive || hasTerminalOutcome) break + + val statusResult = paymentService.checkPaymentStatus(paymentId) + statusResult.onSuccess { state -> + when (state) { + PaymentState.PAID -> { + btcPayPollingActive = false + handleLightningPaymentSuccess() + } + PaymentState.EXPIRED -> { + btcPayPollingActive = false + pendingPaymentId?.let { + PaymentsHistoryActivity.markPaymentExpired(this@PaymentRequestActivity, it) + } + handlePaymentError("Invoice expired") + } + PaymentState.FAILED -> { + btcPayPollingActive = false + pendingPaymentId?.let { + PaymentsHistoryActivity.markPaymentExpired(this@PaymentRequestActivity, it) + } + handlePaymentError("Invoice invalid") + } + PaymentState.PENDING -> { + // Continue polling + } + } + }.onFailure { error -> + Log.w(TAG, "BTCPay poll error: ${error.message}") + // Continue polling on transient errors + } + } + } + } + private fun setHceToCashu() { val request = hcePaymentRequest ?: run { Log.w(TAG, "setHceToCashu() called but hcePaymentRequest is null") @@ -715,6 +892,25 @@ class PaymentRequestActivity : AppCompatActivity() { Log.d(TAG, "NFC token received, cancelled safety timeout") try { + // If using BTCPay, redeem the token via the BTCNutServer API. + if (paymentService is BTCPayPaymentService) { + val invoiceId = btcPayPaymentId + if (invoiceId != null) { + Log.d(TAG, "Redeeming NFC token via BTCPay /cashu/pay-invoice") + val result = paymentService.redeemToken(token, invoiceId) + result.onSuccess { + withContext(Dispatchers.Main) { + handleLightningPaymentSuccess() + } + }.onFailure { e -> + throw Exception("BTCPay redemption failed: ${e.message}") + } + return@launch + } else { + Log.w(TAG, "BTCPay invoice ID not available, falling back to local flow (likely to fail)") + } + } + val paymentId = pendingPaymentId val paymentContext = com.electricdreams.numo.payment.SwapToLightningMintManager.PaymentContext( paymentId = paymentId, @@ -955,6 +1151,9 @@ class PaymentRequestActivity : AppCompatActivity() { hasTerminalOutcome = true cancelNfcSafetyTimeout() + // Stop BTCPay polling + btcPayPollingActive = false + // Stop Nostr handler nostrHandler?.stop() nostrHandler = null diff --git a/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt b/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt index 48285892..59ac590c 100644 --- a/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt +++ b/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt @@ -4,6 +4,9 @@ import android.content.Context import android.util.Log import com.electricdreams.numo.core.util.MintManager import com.electricdreams.numo.core.prefs.PreferenceStore +import com.electricdreams.numo.core.wallet.TemporaryMintWalletFactory +import com.electricdreams.numo.core.wallet.WalletProvider +import com.electricdreams.numo.core.wallet.impl.CdkWalletProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -216,6 +219,26 @@ object CashuWalletManager : MintManager.MintChangeListener { /** Current database instance, mostly for debugging or future use. */ fun getDatabase(): WalletSqliteDatabase? = database + // Lazy-initialized WalletProvider backed by this manager's wallet + private val walletProviderInstance: CdkWalletProvider by lazy { + CdkWalletProvider { wallet } + } + + /** + * Get the WalletProvider interface for wallet operations. + * This provides a CDK-agnostic interface that can be swapped for + * alternative implementations (e.g., BTCPayServer + btcnutserver). + */ + @JvmStatic + fun getWalletProvider(): WalletProvider = walletProviderInstance + + /** + * Get the TemporaryMintWalletFactory for creating temporary wallets. + * Used for swap-to-Lightning-mint flows with unknown mints. + */ + @JvmStatic + fun getTemporaryMintWalletFactory(): TemporaryMintWalletFactory = walletProviderInstance + /** * Get the balance for a specific mint in satoshis. */ diff --git a/app/src/main/java/com/electricdreams/numo/core/data/model/PaymentHistoryEntry.kt b/app/src/main/java/com/electricdreams/numo/core/data/model/PaymentHistoryEntry.kt index 37f73e6f..c61a2ca5 100644 --- a/app/src/main/java/com/electricdreams/numo/core/data/model/PaymentHistoryEntry.kt +++ b/app/src/main/java/com/electricdreams/numo/core/data/model/PaymentHistoryEntry.kt @@ -148,6 +148,9 @@ data class PaymentHistoryEntry( /** Check if this payment is completed */ fun isCompleted(): Boolean = getStatus() == STATUS_COMPLETED + /** Check if this payment expired (BTCPay invoice expired before payment) */ + fun isExpired(): Boolean = getStatus() == STATUS_EXPIRED + /** Check if this payment was via Lightning */ fun isLightning(): Boolean = paymentType == TYPE_LIGHTNING @@ -186,6 +189,7 @@ data class PaymentHistoryEntry( const val STATUS_PENDING = "pending" const val STATUS_COMPLETED = "completed" const val STATUS_CANCELLED = "cancelled" + const val STATUS_EXPIRED = "expired" const val TYPE_CASHU = "cashu" const val TYPE_LIGHTNING = "lightning" diff --git a/app/src/main/java/com/electricdreams/numo/core/payment/BTCPayConfig.kt b/app/src/main/java/com/electricdreams/numo/core/payment/BTCPayConfig.kt new file mode 100644 index 00000000..ad743b36 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/BTCPayConfig.kt @@ -0,0 +1,13 @@ +package com.electricdreams.numo.core.payment + +/** + * Configuration for connecting to a BTCPay Server instance. + */ +data class BTCPayConfig( + /** Base URL of the BTCPay Server (e.g. "https://btcpay.example.com") */ + val serverUrl: String, + /** Greenfield API key */ + val apiKey: String, + /** Store ID within BTCPay */ + val storeId: String +) diff --git a/app/src/main/java/com/electricdreams/numo/core/payment/PaymentService.kt b/app/src/main/java/com/electricdreams/numo/core/payment/PaymentService.kt new file mode 100644 index 00000000..4f706ad9 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/PaymentService.kt @@ -0,0 +1,48 @@ +package com.electricdreams.numo.core.payment + +import com.electricdreams.numo.core.wallet.WalletResult + +/** + * Abstraction for POS payment operations. + * + * Implementations: + * - [com.electricdreams.numo.core.payment.impl.LocalPaymentService] – wraps existing CDK flow + * - [com.electricdreams.numo.core.payment.impl.BTCPayPaymentService] – BTCPay Greenfield API + BTCNutServer + */ +interface PaymentService { + + /** + * Create a new payment (invoice / mint quote). + * + * @param amountSats Amount in satoshis + * @param description Optional human-readable description + * @return [PaymentData] with invoice details + */ + suspend fun createPayment( + amountSats: Long, + description: String? = null + ): WalletResult + + /** + * Poll the current status of a payment. + * + * @param paymentId The id returned in [PaymentData.paymentId] + */ + suspend fun checkPaymentStatus(paymentId: String): WalletResult + + /** + * Redeem a Cashu token received for a payment. + * + * @param token Encoded Cashu token string + * @param paymentId Optional payment/invoice id (used by BTCPay to link token to invoice) + */ + suspend fun redeemToken( + token: String, + paymentId: String? = null + ): WalletResult + + /** + * Whether the service is ready to create payments. + */ + fun isReady(): Boolean +} diff --git a/app/src/main/java/com/electricdreams/numo/core/payment/PaymentServiceFactory.kt b/app/src/main/java/com/electricdreams/numo/core/payment/PaymentServiceFactory.kt new file mode 100644 index 00000000..66157f07 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/PaymentServiceFactory.kt @@ -0,0 +1,41 @@ +package com.electricdreams.numo.core.payment + +import android.content.Context +import com.electricdreams.numo.core.cashu.CashuWalletManager +import com.electricdreams.numo.core.payment.impl.BTCPayPaymentService +import com.electricdreams.numo.core.payment.impl.LocalPaymentService +import com.electricdreams.numo.core.prefs.PreferenceStore +import com.electricdreams.numo.core.util.MintManager + +/** + * Creates the appropriate [PaymentService] based on user settings. + * + * When `btcpay_enabled` is true **and** the BTCPay configuration is complete + * the factory returns a [BTCPayPaymentService]; otherwise it falls back to + * the [LocalPaymentService] that wraps the existing CDK wallet flow. + */ +object PaymentServiceFactory { + + fun create(context: Context): PaymentService { + val prefs = PreferenceStore.app(context) + + if (prefs.getBoolean("btcpay_enabled", false)) { + val config = BTCPayConfig( + serverUrl = prefs.getString("btcpay_server_url") ?: "", + apiKey = prefs.getString("btcpay_api_key") ?: "", + storeId = prefs.getString("btcpay_store_id") ?: "" + ) + if (config.serverUrl.isNotBlank() + && config.apiKey.isNotBlank() + && config.storeId.isNotBlank() + ) { + return BTCPayPaymentService(config) + } + } + + return LocalPaymentService( + walletProvider = CashuWalletManager.getWalletProvider(), + mintManager = MintManager.getInstance(context) + ) + } +} diff --git a/app/src/main/java/com/electricdreams/numo/core/payment/PaymentTypes.kt b/app/src/main/java/com/electricdreams/numo/core/payment/PaymentTypes.kt new file mode 100644 index 00000000..18b9b117 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/PaymentTypes.kt @@ -0,0 +1,39 @@ +package com.electricdreams.numo.core.payment + +import com.electricdreams.numo.core.wallet.Satoshis + +/** + * Data returned after creating a payment. + */ +data class PaymentData( + /** Quote ID (local) or invoice ID (BTCPay) */ + val paymentId: String, + /** BOLT11 Lightning invoice (if available) */ + val bolt11: String?, + /** Cashu payment request (`creq…`) from BTCNutServer – null for local mode where Numo builds its own */ + val cashuPR: String?, + /** Mint URL used for the quote (local mode) – null for BTCPay */ + val mintUrl: String?, + /** Unix-epoch expiry timestamp (optional) */ + val expiresAt: Long? = null +) + +/** + * Possible states of a payment. + */ +enum class PaymentState { + PENDING, + PAID, + EXPIRED, + FAILED +} + +/** + * Result of redeeming a Cashu token for a payment. + */ +data class RedeemResult( + /** Amount received in satoshis */ + val amount: Satoshis, + /** Number of proofs received */ + val proofsCount: Int +) diff --git a/app/src/main/java/com/electricdreams/numo/core/payment/impl/BTCPayPaymentService.kt b/app/src/main/java/com/electricdreams/numo/core/payment/impl/BTCPayPaymentService.kt new file mode 100644 index 00000000..0195c356 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/impl/BTCPayPaymentService.kt @@ -0,0 +1,267 @@ +package com.electricdreams.numo.core.payment.impl + +import android.util.Log +import com.electricdreams.numo.core.payment.BTCPayConfig +import com.electricdreams.numo.core.payment.PaymentData +import com.electricdreams.numo.core.payment.PaymentService +import com.electricdreams.numo.core.payment.PaymentState +import com.electricdreams.numo.core.payment.RedeemResult +import com.electricdreams.numo.core.wallet.Satoshis +import com.electricdreams.numo.core.wallet.WalletError +import com.electricdreams.numo.core.wallet.WalletResult + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * [PaymentService] backed by a BTCPay Server (Greenfield API) + BTCNutServer. + * + * Flow: + * 1. `createPayment()` creates an invoice via BTCPay, then fetches payment methods + * to obtain the BOLT11 invoice and Cashu payment request (`creq…`). + * 2. `checkPaymentStatus()` polls the invoice status. + * 3. `redeemToken()` posts the Cashu token to BTCNutServer to settle the invoice. + */ +class BTCPayPaymentService( + private val config: BTCPayConfig +) : PaymentService { + + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + private val gson = Gson() + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + // ------------------------------------------------------------------- + // PaymentService + // ------------------------------------------------------------------- + + override suspend fun createPayment( + amountSats: Long, + description: String? + ): WalletResult = withContext(Dispatchers.IO) { + WalletResult.runCatching { + // 1. Create invoice + val invoiceId = createInvoice(amountSats, description) + + // 2. Fetch payment methods to get bolt11 + cashu PR. + // BTCPay may return null destinations on the first call while + // it generates the lightning invoice, so retry a few times. + var bolt11: String? = null + var cashuPR: String? = null + + // Cashu is almost always faster than Lightning — break as soon as cashuPR + // is available. bolt11 is fetched in the background by the caller if needed. + for (attempt in 1..3) { + val (b, c) = fetchPaymentMethods(invoiceId) + if (b != null) bolt11 = b + if (c != null) cashuPR = c + if (cashuPR != null) break + Log.d(TAG, "Payment methods attempt $attempt: bolt11=${bolt11 != null}, cashuPR=${cashuPR != null}") + delay(1000) + } + + PaymentData( + paymentId = invoiceId, + bolt11 = bolt11, + cashuPR = cashuPR, + mintUrl = null, + expiresAt = null + ) + } + } + + override suspend fun checkPaymentStatus(paymentId: String): WalletResult = + withContext(Dispatchers.IO) { + WalletResult.runCatching { + val url = "${baseUrl()}/api/v1/stores/${config.storeId}/invoices/$paymentId" + val request = authorizedGet(url) + val body = executeForBody(request) + val json = JsonParser.parseString(body).asJsonObject + val status = json.get("status")?.takeIf { !it.isJsonNull }?.asString ?: "Invalid" + Log.d(TAG, "Invoice $paymentId status: $status") + mapInvoiceStatus(status) + } + } + + override suspend fun redeemToken( + token: String, + paymentId: String? + ): WalletResult = withContext(Dispatchers.IO) { + WalletResult.runCatching { + val urlBuilder = StringBuilder("${baseUrl()}/cashu/pay-invoice?token=$token") + if (!paymentId.isNullOrBlank()) { + urlBuilder.append("&invoiceId=$paymentId") + } + val request = Request.Builder() + .url(urlBuilder.toString()) + .post("".toRequestBody(jsonMediaType)) + .addHeader("Authorization", "token ${config.apiKey}") + .build() + + executeForBody(request) + + // BTCNutServer does not return detailed amount info; return a + // placeholder so the caller knows the operation succeeded. + RedeemResult(amount = Satoshis(0), proofsCount = 0) + } + } + + suspend fun redeemTokenToPostEndpoint( + token: String, + requestId: String, + postUrl: String + ): WalletResult = withContext(Dispatchers.IO) { + WalletResult.runCatching { + // Simplified payload: send ID and the raw token string + val payload = JsonObject() + payload.addProperty("id", requestId) + payload.addProperty("token", token) + val payloadJson = payload.toString() + + val request = Request.Builder() + .url(postUrl) + .post(payloadJson.toRequestBody(jsonMediaType)) + .addHeader("Authorization", "token ${config.apiKey}") + .build() + + executeForBody(request) + + RedeemResult(amount = Satoshis(0), proofsCount = 0) + } + } + + /** + * Fetch the Lightning bolt11 invoice for an already-created invoice. + * Used when [createPayment] returned before bolt11 was ready. + */ + suspend fun fetchLightningInvoice(invoiceId: String): String? = withContext(Dispatchers.IO) { + fetchPaymentMethods(invoiceId).first + } + + override fun isReady(): Boolean { + return config.serverUrl.isNotBlank() + && config.apiKey.isNotBlank() + && config.storeId.isNotBlank() + } + + // ------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------- + + private fun baseUrl(): String = config.serverUrl.trimEnd('/') + + private fun authorizedGet(url: String): Request = Request.Builder() + .url(url) + .get() + .addHeader("Authorization", "token ${config.apiKey}") + .build() + + /** + * Create a BTCPay invoice and return the invoice ID. + */ + private fun createInvoice(amountSats: Long, description: String?): String { + val payload = JsonObject().apply { + // BTCPay expects the amount as a string in the currency unit. + // For BTC-denominated stores this is BTC; for sats-denominated stores + // it is sats. We pass sats and rely on the store being configured for + // the "SATS" denomination. + addProperty("amount", amountSats.toString()) + addProperty("currency", "SATS") + if (!description.isNullOrBlank()) { + val metadata = JsonObject() + metadata.addProperty("itemDesc", description) + add("metadata", metadata) + } + } + + val request = Request.Builder() + .url("${baseUrl()}/api/v1/stores/${config.storeId}/invoices") + .post(gson.toJson(payload).toRequestBody(jsonMediaType)) + .addHeader("Authorization", "token ${config.apiKey}") + .build() + + val body = executeForBody(request) + val json = JsonParser.parseString(body).asJsonObject + return json.get("id")?.asString + ?: throw WalletError.Unknown("BTCPay invoice response missing 'id'") + } + + /** + * Fetch payment methods for an invoice. + * Returns (bolt11, cashuPR) – either may be null if the method is not available. + */ + private fun fetchPaymentMethods(invoiceId: String): Pair { + val url = "${baseUrl()}/api/v1/stores/${config.storeId}/invoices/$invoiceId/payment-methods" + val request = authorizedGet(url) + val body = executeForBody(request) + Log.d(TAG, "Payment methods response: $body") + val array = JsonParser.parseString(body).asJsonArray + + var bolt11: String? = null + var cashuPR: String? = null + + for (element in array) { + val obj = element.asJsonObject + val paymentMethod = obj.get("paymentMethodId")?.takeIf { !it.isJsonNull }?.asString + ?: obj.get("paymentMethod")?.takeIf { !it.isJsonNull }?.asString + ?: "" + val destination = obj.get("destination")?.takeIf { !it.isJsonNull }?.asString + + Log.d(TAG, "Payment method: '$paymentMethod', destination: ${if (destination != null) "'${destination.take(30)}...'" else "null"}") + + when { + paymentMethod.equals("BTC-LN", ignoreCase = true) + || paymentMethod.contains("LightningNetwork", ignoreCase = true) -> { + if (destination != null) bolt11 = destination + } + paymentMethod.contains("Cashu", ignoreCase = true) -> { + if (destination != null) cashuPR = destination + } + } + } + + Log.d(TAG, "Resolved bolt11=${bolt11 != null}, cashuPR=${cashuPR != null}") + return Pair(bolt11, cashuPR) + } + + private fun executeForBody(request: Request): String { + val response = client.newCall(request).execute() + val body = response.body?.string() + if (!response.isSuccessful) { + Log.e(TAG, "BTCPay request failed: ${response.code} ${response.message} body=$body") + throw WalletError.NetworkError( + "BTCPay request failed (${response.code}): ${body?.take(200) ?: response.message}" + ) + } + return body ?: throw WalletError.NetworkError("Empty response body from BTCPay") + } + + private fun mapInvoiceStatus(status: String): PaymentState = when (status) { + "New" -> PaymentState.PENDING + "Processing" -> PaymentState.PENDING + "Settled" -> PaymentState.PAID + "Expired" -> PaymentState.EXPIRED + "Invalid" -> PaymentState.FAILED + else -> { + Log.w(TAG, "Unknown BTCPay invoice status: $status") + PaymentState.FAILED + } + } + + companion object { + private const val TAG = "BtcPayPaymentService" + } +} diff --git a/app/src/main/java/com/electricdreams/numo/core/payment/impl/LocalPaymentService.kt b/app/src/main/java/com/electricdreams/numo/core/payment/impl/LocalPaymentService.kt new file mode 100644 index 00000000..6238179d --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/impl/LocalPaymentService.kt @@ -0,0 +1,85 @@ +package com.electricdreams.numo.core.payment.impl + +import com.electricdreams.numo.core.payment.PaymentData +import com.electricdreams.numo.core.payment.PaymentService +import com.electricdreams.numo.core.payment.PaymentState +import com.electricdreams.numo.core.payment.RedeemResult +import com.electricdreams.numo.core.util.MintManager +import com.electricdreams.numo.core.wallet.QuoteStatus +import com.electricdreams.numo.core.wallet.Satoshis +import com.electricdreams.numo.core.wallet.WalletProvider +import com.electricdreams.numo.core.wallet.WalletResult + +/** + * [PaymentService] backed by the local CDK wallet. + * + * Pure delegation — no new business logic. The existing Cashu/Lightning flows + * in [com.electricdreams.numo.payment.LightningMintHandler] and + * [com.electricdreams.numo.ndef.CashuPaymentHelper] continue to work unchanged. + */ +class LocalPaymentService( + private val walletProvider: WalletProvider, + private val mintManager: MintManager +) : PaymentService { + + override suspend fun createPayment( + amountSats: Long, + description: String? + ): WalletResult { + val mintUrl = mintManager.getPreferredLightningMint() + ?: return WalletResult.Failure( + com.electricdreams.numo.core.wallet.WalletError.NotInitialized( + "No preferred Lightning mint configured" + ) + ) + + return walletProvider.requestMintQuote( + mintUrl = mintUrl, + amount = Satoshis(amountSats), + description = description + ).map { quote -> + PaymentData( + paymentId = quote.quoteId, + bolt11 = quote.bolt11Invoice, + cashuPR = null, // Local mode: Numo builds its own creq + mintUrl = mintUrl, + expiresAt = quote.expiryTimestamp + ) + } + } + + override suspend fun checkPaymentStatus(paymentId: String): WalletResult { + val mintUrl = mintManager.getPreferredLightningMint() + ?: return WalletResult.Failure( + com.electricdreams.numo.core.wallet.WalletError.NotInitialized( + "No preferred Lightning mint configured" + ) + ) + + return walletProvider.checkMintQuote(mintUrl, paymentId).map { status -> + when (status.status) { + QuoteStatus.UNPAID -> PaymentState.PENDING + QuoteStatus.PENDING -> PaymentState.PENDING + QuoteStatus.PAID -> PaymentState.PENDING + QuoteStatus.ISSUED -> PaymentState.PAID + QuoteStatus.EXPIRED -> PaymentState.EXPIRED + QuoteStatus.UNKNOWN -> PaymentState.FAILED + } + } + } + + override suspend fun redeemToken( + token: String, + paymentId: String? + ): WalletResult { + // paymentId is ignored for local mode + return walletProvider.receiveToken(token).map { result -> + RedeemResult( + amount = result.amount, + proofsCount = result.proofsCount + ) + } + } + + override fun isReady(): Boolean = walletProvider.isReady() +} diff --git a/app/src/main/java/com/electricdreams/numo/core/wallet/TemporaryMintWallet.kt b/app/src/main/java/com/electricdreams/numo/core/wallet/TemporaryMintWallet.kt new file mode 100644 index 00000000..a222a520 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/wallet/TemporaryMintWallet.kt @@ -0,0 +1,68 @@ +package com.electricdreams.numo.core.wallet + +/** + * Interface for a temporary, single-mint wallet used in swap flows. + * + * This is used for swap-to-Lightning-mint operations where we need to: + * - Interact with an unknown mint (not in the merchant's allowed list) + * - Keep the main wallet's proofs and balances untouched + * - Melt proofs from an incoming token to pay a Lightning invoice + * + * The temporary wallet is ephemeral and should be closed after use. + */ +interface TemporaryMintWallet : AutoCloseable { + + /** + * The mint URL this temporary wallet is connected to. + */ + val mintUrl: String + + /** + * Refresh keysets from the mint. + * Must be called before decoding proofs from a token. + * + * @return List of keyset information from the mint + */ + suspend fun refreshKeysets(): WalletResult> + + /** + * Request a melt quote from this mint for a Lightning invoice. + * + * @param bolt11Invoice The BOLT11 Lightning invoice to pay + * @return Result containing the melt quote or error + */ + suspend fun requestMeltQuote(bolt11Invoice: String): WalletResult + + /** + * Execute a melt operation using provided proofs (from an incoming token). + * This is different from WalletProvider.melt() which uses wallet-held proofs. + * + * @param quoteId The melt quote ID + * @param encodedToken The encoded Cashu token containing proofs to melt + * @return Result containing the melt result or error + */ + suspend fun meltWithToken( + quoteId: String, + encodedToken: String + ): WalletResult + + /** + * Close and cleanup this temporary wallet. + * After closing, the wallet should not be used. + */ + override fun close() +} + +/** + * Factory interface for creating temporary mint wallets. + * Implementations provide wallet instances for unknown mints. + */ +interface TemporaryMintWalletFactory { + /** + * Create a temporary wallet for interacting with the specified mint. + * + * @param mintUrl The mint URL to connect to + * @return Result containing the temporary wallet or error + */ + suspend fun createTemporaryWallet(mintUrl: String): WalletResult +} diff --git a/app/src/main/java/com/electricdreams/numo/core/wallet/WalletError.kt b/app/src/main/java/com/electricdreams/numo/core/wallet/WalletError.kt new file mode 100644 index 00000000..3edb137d --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/wallet/WalletError.kt @@ -0,0 +1,174 @@ +package com.electricdreams.numo.core.wallet + +/** + * Sealed class hierarchy for wallet errors. + * Provides type-safe error handling independent of implementation. + */ +sealed class WalletError : Exception() { + + /** Wallet is not initialized or not ready for operations */ + data class NotInitialized( + override val message: String = "Wallet not initialized" + ) : WalletError() + + /** Invalid mint URL format */ + data class InvalidMintUrl( + val mintUrl: String, + override val message: String = "Invalid mint URL: $mintUrl" + ) : WalletError() + + /** Mint is not reachable or not responding */ + data class MintUnreachable( + val mintUrl: String, + override val message: String = "Mint unreachable: $mintUrl", + override val cause: Throwable? = null + ) : WalletError() + + /** Quote not found or expired */ + data class QuoteNotFound( + val quoteId: String, + override val message: String = "Quote not found: $quoteId" + ) : WalletError() + + /** Quote has expired */ + data class QuoteExpired( + val quoteId: String, + override val message: String = "Quote expired: $quoteId" + ) : WalletError() + + /** Quote is not in the expected state for the operation */ + data class InvalidQuoteState( + val quoteId: String, + val expectedState: QuoteStatus, + val actualState: QuoteStatus, + override val message: String = "Quote $quoteId in invalid state: expected $expectedState, got $actualState" + ) : WalletError() + + /** Insufficient balance for the operation */ + data class InsufficientBalance( + val required: Satoshis, + val available: Satoshis, + override val message: String = "Insufficient balance: required ${required.value} sats, available ${available.value} sats" + ) : WalletError() + + /** Token is invalid or malformed */ + data class InvalidToken( + override val message: String = "Invalid token format", + override val cause: Throwable? = null + ) : WalletError() + + /** Token has already been spent */ + data class TokenAlreadySpent( + override val message: String = "Token has already been spent" + ) : WalletError() + + /** Proofs are invalid or verification failed */ + data class InvalidProofs( + override val message: String = "Invalid proofs", + override val cause: Throwable? = null + ) : WalletError() + + /** Melt operation failed */ + data class MeltFailed( + val quoteId: String, + override val message: String = "Melt operation failed for quote: $quoteId", + override val cause: Throwable? = null + ) : WalletError() + + /** Mint operation failed */ + data class MintFailed( + val quoteId: String, + override val message: String = "Mint operation failed for quote: $quoteId", + override val cause: Throwable? = null + ) : WalletError() + + /** Network error during wallet operation */ + data class NetworkError( + override val message: String = "Network error", + override val cause: Throwable? = null + ) : WalletError() + + /** Generic/unknown error wrapping underlying exception */ + data class Unknown( + override val message: String = "Unknown wallet error", + override val cause: Throwable? = null + ) : WalletError() +} + +/** + * Result type for wallet operations that can fail. + * Provides a functional way to handle success/failure without exceptions. + */ +sealed class WalletResult { + data class Success(val value: T) : WalletResult() + data class Failure(val error: WalletError) : WalletResult() + + /** + * Returns the value if success, or throws the error if failure. + */ + fun getOrThrow(): T = when (this) { + is Success -> value + is Failure -> throw error + } + + /** + * Returns the value if success, or null if failure. + */ + fun getOrNull(): T? = when (this) { + is Success -> value + is Failure -> null + } + + /** + * Returns the value if success, or the default value if failure. + */ + fun getOrDefault(default: @UnsafeVariance T): T = when (this) { + is Success -> value + is Failure -> default + } + + /** + * Maps the success value using the provided transform function. + */ + inline fun map(transform: (T) -> R): WalletResult = when (this) { + is Success -> Success(transform(value)) + is Failure -> this + } + + /** + * Flat maps the success value using the provided transform function. + */ + inline fun flatMap(transform: (T) -> WalletResult): WalletResult = when (this) { + is Success -> transform(value) + is Failure -> this + } + + /** + * Executes the given block if this is a success. + */ + inline fun onSuccess(block: (T) -> Unit): WalletResult { + if (this is Success) block(value) + return this + } + + /** + * Executes the given block if this is a failure. + */ + inline fun onFailure(block: (WalletError) -> Unit): WalletResult { + if (this is Failure) block(error) + return this + } + + companion object { + /** + * Wraps a potentially throwing operation into a WalletResult. + */ + inline fun runCatching(block: () -> T): WalletResult = try { + Success(block()) + } catch (e: WalletError) { + Failure(e) + } catch (e: Exception) { + Failure(WalletError.Unknown(e.message ?: "Unknown error", e)) + } + } +} diff --git a/app/src/main/java/com/electricdreams/numo/core/wallet/WalletProvider.kt b/app/src/main/java/com/electricdreams/numo/core/wallet/WalletProvider.kt new file mode 100644 index 00000000..436a753f --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/wallet/WalletProvider.kt @@ -0,0 +1,158 @@ +package com.electricdreams.numo.core.wallet + +/** + * Main interface for wallet operations. + * + * This abstraction allows swapping between different wallet implementations + * (e.g., CDK, BTCPayServer + btcnutserver) without changing the consuming code. + * + * All methods are suspending functions as they may involve network I/O. + */ +interface WalletProvider { + + // ======================================================================== + // Balance Operations + // ======================================================================== + + /** + * Get the balance for a specific mint. + * + * @param mintUrl The mint URL to get balance for + * @return Balance in satoshis, or 0 if mint is not registered or has no balance + */ + suspend fun getBalance(mintUrl: String): Satoshis + + /** + * Get balances for all registered mints. + * + * @return Map of mint URL to balance in satoshis + */ + suspend fun getAllBalances(): Map + + // ======================================================================== + // Lightning Receive Flow (Mint Quote -> Mint) + // ======================================================================== + + /** + * Request a mint quote to receive Lightning payment. + * This creates a Lightning invoice that, when paid, allows minting proofs. + * + * @param mintUrl The mint to request quote from + * @param amount Amount in satoshis to receive + * @param description Optional description for the payment + * @return Result containing the quote details or error + */ + suspend fun requestMintQuote( + mintUrl: String, + amount: Satoshis, + description: String? = null + ): WalletResult + + /** + * Check the status of an existing mint quote. + * + * @param mintUrl The mint URL + * @param quoteId The quote ID to check + * @return Result containing the quote status or error + */ + suspend fun checkMintQuote( + mintUrl: String, + quoteId: String + ): WalletResult + + /** + * Mint proofs after a Lightning invoice has been paid. + * Should only be called when quote status is PAID. + * + * @param mintUrl The mint URL + * @param quoteId The quote ID for which to mint proofs + * @return Result containing the mint result or error + */ + suspend fun mint( + mintUrl: String, + quoteId: String + ): WalletResult + + // ======================================================================== + // Lightning Spend Flow (Melt Quote -> Melt) + // ======================================================================== + + /** + * Request a melt quote to pay a Lightning invoice. + * + * @param mintUrl The mint to request quote from + * @param bolt11Invoice The BOLT11 Lightning invoice to pay + * @return Result containing the quote details or error + */ + suspend fun requestMeltQuote( + mintUrl: String, + bolt11Invoice: String + ): WalletResult + + /** + * Execute a melt operation to pay a Lightning invoice. + * Uses proofs from the wallet to pay the invoice via the mint. + * + * @param mintUrl The mint URL + * @param quoteId The melt quote ID + * @return Result containing the melt result or error + */ + suspend fun melt( + mintUrl: String, + quoteId: String + ): WalletResult + + /** + * Check the status of an existing melt quote. + * + * @param mintUrl The mint URL + * @param quoteId The quote ID to check + * @return Result containing the quote status or error + */ + suspend fun checkMeltQuote( + mintUrl: String, + quoteId: String + ): WalletResult + + // ======================================================================== + // Cashu Token Operations + // ======================================================================== + + /** + * Receive (redeem) a Cashu token. + * The token's proofs are received into the wallet. + * + * @param encodedToken The encoded Cashu token string (cashuA..., cashuB..., crawB...) + * @return Result containing the receive result or error + */ + suspend fun receiveToken(encodedToken: String): WalletResult + + /** + * Get information about a Cashu token without redeeming it. + * + * @param encodedToken The encoded Cashu token string + * @return Result containing token info or error + */ + suspend fun getTokenInfo(encodedToken: String): WalletResult + + // ======================================================================== + // Mint Information + // ======================================================================== + + /** + * Fetch information about a mint. + * + * @param mintUrl The mint URL to fetch info for + * @return Result containing mint info or error + */ + suspend fun fetchMintInfo(mintUrl: String): WalletResult + + // ======================================================================== + // Lifecycle + // ======================================================================== + + /** + * Check if the wallet provider is ready for operations. + */ + fun isReady(): Boolean +} diff --git a/app/src/main/java/com/electricdreams/numo/core/wallet/WalletTypes.kt b/app/src/main/java/com/electricdreams/numo/core/wallet/WalletTypes.kt new file mode 100644 index 00000000..e471e410 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/wallet/WalletTypes.kt @@ -0,0 +1,188 @@ +package com.electricdreams.numo.core.wallet + +/** + * Domain types for the wallet abstraction layer. + * These types are independent of any specific wallet implementation (CDK, BTCPay, etc.) + */ + +/** + * Represents an amount in satoshis. + */ +@JvmInline +value class Satoshis(val value: Long) { + init { + require(value >= 0) { "Satoshis cannot be negative" } + } + + operator fun plus(other: Satoshis): Satoshis = Satoshis(value + other.value) + operator fun minus(other: Satoshis): Satoshis = Satoshis(value - other.value) + operator fun compareTo(other: Satoshis): Int = value.compareTo(other.value) + + companion object { + val ZERO = Satoshis(0) + + fun fromULong(value: ULong): Satoshis = Satoshis(value.toLong()) + } +} + +/** + * Status of a mint or melt quote. + */ +enum class QuoteStatus { + /** Quote is created but not yet paid */ + UNPAID, + /** Payment is pending/in-progress */ + PENDING, + /** Quote is paid */ + PAID, + /** Proofs have been issued (for mint quotes) */ + ISSUED, + /** Quote has expired */ + EXPIRED, + /** Unknown status */ + UNKNOWN +} + +/** + * Result of creating a mint quote (Lightning receive). + */ +data class MintQuoteResult( + /** Unique identifier for this quote */ + val quoteId: String, + /** BOLT11 Lightning invoice to be paid */ + val bolt11Invoice: String, + /** Amount requested in satoshis */ + val amount: Satoshis, + /** Current status of the quote */ + val status: QuoteStatus, + /** Unix timestamp when the quote expires (optional) */ + val expiryTimestamp: Long? = null +) + +/** + * Result of checking a mint quote status. + */ +data class MintQuoteStatusResult( + /** Unique identifier for this quote */ + val quoteId: String, + /** Current status of the quote */ + val status: QuoteStatus, + /** Unix timestamp when the quote expires (optional) */ + val expiryTimestamp: Long? = null +) + +/** + * Result of minting proofs (after Lightning invoice is paid). + */ +data class MintResult( + /** Number of proofs minted */ + val proofsCount: Int, + /** Total amount minted in satoshis */ + val amount: Satoshis +) + +/** + * Result of creating a melt quote (Lightning spend). + */ +data class MeltQuoteResult( + /** Unique identifier for this quote */ + val quoteId: String, + /** Amount to be melted (paid) in satoshis */ + val amount: Satoshis, + /** Fee reserve required for this payment */ + val feeReserve: Satoshis, + /** Current status of the quote */ + val status: QuoteStatus, + /** Unix timestamp when the quote expires (optional) */ + val expiryTimestamp: Long? = null +) + +/** + * Result of executing a melt operation. + */ +data class MeltResult( + /** Whether the melt was successful */ + val success: Boolean, + /** Current status after melt */ + val status: QuoteStatus, + /** Actual fee paid (may be less than reserved) */ + val feePaid: Satoshis, + /** Payment preimage (proof of payment) if available */ + val preimage: String? = null, + /** Change proofs count (if any change was returned) */ + val changeProofsCount: Int = 0 +) + +/** + * Information about a received Cashu token. + */ +data class TokenInfo( + /** Mint URL the token is from */ + val mintUrl: String, + /** Total value of the token in satoshis */ + val amount: Satoshis, + /** Number of proofs in the token */ + val proofsCount: Int, + /** Currency unit (should be "sat" for satoshis) */ + val unit: String +) + +/** + * Result of receiving (redeeming) a Cashu token. + */ +data class ReceiveResult( + /** Amount received in satoshis */ + val amount: Satoshis, + /** Number of proofs received */ + val proofsCount: Int +) + +/** + * Version information for a mint. + */ +data class MintVersionInfo( + val name: String?, + val version: String? +) + +/** + * Contact information for a mint. + */ +data class MintContactInfo( + val method: String, + val info: String +) + +/** + * Information about a mint. + */ +data class MintInfoResult( + /** Human-readable name of the mint */ + val name: String?, + /** Short description */ + val description: String?, + /** Detailed description */ + val descriptionLong: String?, + /** Mint's public key */ + val pubkey: String?, + /** Version information */ + val version: MintVersionInfo?, + /** Message of the day */ + val motd: String?, + /** URL to mint's icon/logo */ + val iconUrl: String?, + /** Contact information */ + val contacts: List +) + +/** + * Keyset information from a mint. + */ +data class KeysetInfo( + /** Keyset identifier */ + val id: String, + /** Whether this keyset is active */ + val active: Boolean, + /** Currency unit for this keyset */ + val unit: String +) diff --git a/app/src/main/java/com/electricdreams/numo/core/wallet/impl/CdkWalletProvider.kt b/app/src/main/java/com/electricdreams/numo/core/wallet/impl/CdkWalletProvider.kt new file mode 100644 index 00000000..8c97d709 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/wallet/impl/CdkWalletProvider.kt @@ -0,0 +1,508 @@ +package com.electricdreams.numo.core.wallet.impl + +import android.util.Log +import com.electricdreams.numo.core.wallet.* +import org.cashudevkit.Amount as CdkAmount +import org.cashudevkit.CurrencyUnit +import org.cashudevkit.MintUrl +import org.cashudevkit.PaymentMethod +import org.cashudevkit.QuoteState as CdkQuoteState +import org.cashudevkit.ReceiveOptions +import org.cashudevkit.SplitTarget +import org.cashudevkit.Token as CdkToken +import org.cashudevkit.Wallet as CdkWallet +import org.cashudevkit.WalletConfig +import org.cashudevkit.WalletRepository +import org.cashudevkit.WalletSqliteDatabase +import org.cashudevkit.generateMnemonic + +/** + * CDK-based implementation of WalletProvider. + * + * This implementation wraps the CDK WalletRepository to provide wallet + * operations through the WalletProvider interface. + * + * @param walletProvider Function that returns the current CDK WalletRepository instance + */ +class CdkWalletProvider( + private val walletProvider: () -> WalletRepository? +) : WalletProvider, TemporaryMintWalletFactory { + + companion object { + private const val TAG = "CdkWalletProvider" + } + + private val wallet: WalletRepository? + get() = walletProvider() + + // ======================================================================== + // Balance Operations + // ======================================================================== + + override suspend fun getBalance(mintUrl: String): Satoshis { + val w = wallet ?: return Satoshis.ZERO + return try { + val balances = w.getBalances() + val normalizedInput = mintUrl.removeSuffix("/") + for (entry in balances) { + val cdkUrl = entry.key.mintUrl.url.removeSuffix("/") + if (cdkUrl == normalizedInput && entry.key.unit == CurrencyUnit.Sat) { + return Satoshis(entry.value.value.toLong()) + } + } + Satoshis.ZERO + } catch (e: Exception) { + Log.e(TAG, "Error getting balance for mint $mintUrl: ${e.message}", e) + Satoshis.ZERO + } + } + + override suspend fun getAllBalances(): Map { + val w = wallet ?: return emptyMap() + return try { + val balanceMap = w.getBalances() + balanceMap + .filter { it.key.unit == CurrencyUnit.Sat } + .mapKeys { it.key.mintUrl.url.removeSuffix("/") } + .mapValues { Satoshis(it.value.value.toLong()) } + } catch (e: Exception) { + Log.e(TAG, "Error getting all balances: ${e.message}", e) + emptyMap() + } + } + + // ======================================================================== + // Lightning Receive Flow (Mint Quote -> Mint) + // ======================================================================== + + override suspend fun requestMintQuote( + mintUrl: String, + amount: Satoshis, + description: String? + ): WalletResult { + val w = wallet + ?: return WalletResult.Failure(WalletError.NotInitialized()) + + return try { + val cdkMintUrl = MintUrl(mintUrl) + val cdkAmount = CdkAmount(amount.value.toULong()) + val mintWallet = w.getWallet(cdkMintUrl, CurrencyUnit.Sat) + ?: return WalletResult.Failure(WalletError.MintUnreachable(mintUrl, "Wallet not found for mint")) + + Log.d(TAG, "Requesting mint quote from $mintUrl for ${amount.value} sats") + val quote = mintWallet.mintQuote(PaymentMethod.Bolt11, cdkAmount, description, null) + + val result = MintQuoteResult( + quoteId = quote.id, + bolt11Invoice = quote.request, + amount = amount, + status = mapQuoteState(quote.state), + expiryTimestamp = quote.expiry?.toLong() + ) + Log.d(TAG, "Mint quote created: id=${quote.id}") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error requesting mint quote: ${e.message}", e) + WalletResult.Failure(mapException(e, mintUrl)) + } + } + + override suspend fun checkMintQuote( + mintUrl: String, + quoteId: String + ): WalletResult { + val w = wallet + ?: return WalletResult.Failure(WalletError.NotInitialized()) + + return try { + val cdkMintUrl = MintUrl(mintUrl) + val mintWallet = w.getWallet(cdkMintUrl, CurrencyUnit.Sat) + ?: return WalletResult.Failure(WalletError.MintUnreachable(mintUrl, "Wallet not found for mint")) + val quote = mintWallet.checkMintQuote(quoteId) + + val result = MintQuoteStatusResult( + quoteId = quote.id, + status = mapQuoteState(quote.state), + expiryTimestamp = quote.expiry?.toLong() + ) + Log.d(TAG, "Mint quote status: id=${quote.id}, state=${result.status}") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error checking mint quote: ${e.message}", e) + WalletResult.Failure(mapException(e, mintUrl, quoteId)) + } + } + + override suspend fun mint( + mintUrl: String, + quoteId: String + ): WalletResult { + val w = wallet + ?: return WalletResult.Failure(WalletError.NotInitialized()) + + return try { + val cdkMintUrl = MintUrl(mintUrl) + val mintWallet = w.getWallet(cdkMintUrl, CurrencyUnit.Sat) + ?: return WalletResult.Failure(WalletError.MintUnreachable(mintUrl, "Wallet not found for mint")) + + Log.d(TAG, "Minting proofs for quote $quoteId") + val proofs = mintWallet.mint(quoteId, SplitTarget.None, null) + + val result = MintResult( + proofsCount = proofs.size, + amount = Satoshis.ZERO // CDK Proof doesn't expose amount directly + ) + Log.d(TAG, "Minted ${proofs.size} proofs") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error minting proofs: ${e.message}", e) + WalletResult.Failure(WalletError.MintFailed(quoteId, e.message ?: "Mint failed", e)) + } + } + + // ======================================================================== + // Lightning Spend Flow (Melt Quote -> Melt) + // ======================================================================== + + override suspend fun requestMeltQuote( + mintUrl: String, + bolt11Invoice: String + ): WalletResult { + val w = wallet + ?: return WalletResult.Failure(WalletError.NotInitialized()) + + return try { + val cdkMintUrl = MintUrl(mintUrl) + val mintWallet = w.getWallet(cdkMintUrl, CurrencyUnit.Sat) + ?: return WalletResult.Failure(WalletError.MintUnreachable(mintUrl, "Wallet not found for mint")) + + Log.d(TAG, "Requesting melt quote from $mintUrl") + val quote = mintWallet.meltQuote(PaymentMethod.Bolt11, bolt11Invoice, null, null) + + val result = MeltQuoteResult( + quoteId = quote.id, + amount = Satoshis(quote.amount.value.toLong()), + feeReserve = Satoshis(quote.feeReserve.value.toLong()), + status = mapQuoteState(quote.state), + expiryTimestamp = quote.expiry?.toLong() + ) + Log.d(TAG, "Melt quote created: id=${quote.id}, amount=${result.amount.value}, feeReserve=${result.feeReserve.value}") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error requesting melt quote: ${e.message}", e) + WalletResult.Failure(mapException(e, mintUrl)) + } + } + + override suspend fun melt( + mintUrl: String, + quoteId: String + ): WalletResult { + val w = wallet + ?: return WalletResult.Failure(WalletError.NotInitialized()) + + return try { + val cdkMintUrl = MintUrl(mintUrl) + val mintWallet = w.getWallet(cdkMintUrl, CurrencyUnit.Sat) + ?: return WalletResult.Failure(WalletError.MintUnreachable(mintUrl, "Wallet not found for mint")) + + Log.d(TAG, "Executing melt for quote $quoteId") + val prepared = mintWallet.prepareMelt(quoteId) + val finalized = prepared.confirm() + + val result = MeltResult( + success = finalized.state == CdkQuoteState.PAID, + status = mapQuoteState(finalized.state), + feePaid = Satoshis(finalized.feePaid?.value?.toLong() ?: 0L), + preimage = finalized.preimage, + changeProofsCount = finalized.change?.size ?: 0 + ) + Log.d(TAG, "Melt result: success=${result.success}, feePaid=${result.feePaid.value}") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error executing melt: ${e.message}", e) + WalletResult.Failure(WalletError.MeltFailed(quoteId, e.message ?: "Melt failed", e)) + } + } + + override suspend fun checkMeltQuote( + mintUrl: String, + quoteId: String + ): WalletResult { + // CDK 0.15.1 Wallet does not expose a checkMeltQuote method directly. + // Re-request the melt quote is not possible without the bolt11, so we + // return a pending status as a safe fallback. + Log.w(TAG, "checkMeltQuote not supported by CDK Wallet, returning PENDING for quoteId=$quoteId") + return WalletResult.Failure(WalletError.Unknown("checkMeltQuote not supported")) + } + + // ======================================================================== + // Cashu Token Operations + // ======================================================================== + + override suspend fun receiveToken(encodedToken: String): WalletResult { + val w = wallet + ?: return WalletResult.Failure(WalletError.NotInitialized()) + + return try { + val cdkToken = CdkToken.decode(encodedToken) + + if (cdkToken.unit() != CurrencyUnit.Sat) { + return WalletResult.Failure( + WalletError.InvalidToken("Unsupported token unit: ${cdkToken.unit()}") + ) + } + + val mintUrl = cdkToken.mintUrl() + val mintWallet = w.getWallet(mintUrl, CurrencyUnit.Sat) + ?: return WalletResult.Failure( + WalletError.MintUnreachable(mintUrl.url, "Wallet not found for mint") + ) + + val receiveOptions = ReceiveOptions( + amountSplitTarget = SplitTarget.None, + p2pkSigningKeys = emptyList(), + preimages = emptyList(), + metadata = emptyMap() + ) + + val totalAmount = cdkToken.value().value.toLong() + Log.d(TAG, "Receiving token from mint ${mintUrl.url}, amount=$totalAmount sats") + mintWallet.receive(cdkToken, receiveOptions) + + val result = ReceiveResult( + amount = Satoshis(totalAmount), + proofsCount = 0 + ) + Log.d(TAG, "Token received: $totalAmount sats") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error receiving token: ${e.message}", e) + val error = when { + e.message?.contains("already spent", ignoreCase = true) == true -> + WalletError.TokenAlreadySpent() + e.message?.contains("invalid", ignoreCase = true) == true -> + WalletError.InvalidToken(e.message ?: "Invalid token", e) + else -> WalletError.Unknown(e.message ?: "Token receive failed", e) + } + WalletResult.Failure(error) + } + } + + override suspend fun getTokenInfo(encodedToken: String): WalletResult { + return try { + val cdkToken = CdkToken.decode(encodedToken) + val result = TokenInfo( + mintUrl = cdkToken.mintUrl().url, + amount = Satoshis(cdkToken.value().value.toLong()), + proofsCount = 0, + unit = cdkToken.unit().toString().lowercase() + ) + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error getting token info: ${e.message}", e) + WalletResult.Failure(WalletError.InvalidToken(e.message ?: "Invalid token", e)) + } + } + + // ======================================================================== + // Mint Information + // ======================================================================== + + override suspend fun fetchMintInfo(mintUrl: String): WalletResult { + val w = wallet + ?: return WalletResult.Failure(WalletError.NotInitialized()) + + return try { + val cdkMintUrl = MintUrl(mintUrl) + val mintWallet = w.getWallet(cdkMintUrl, CurrencyUnit.Sat) + ?: return WalletResult.Failure(WalletError.MintUnreachable(mintUrl, "Wallet not found for mint")) + val info = mintWallet.fetchMintInfo() + ?: return WalletResult.Failure(WalletError.MintUnreachable(mintUrl, "Mint returned no info")) + + val result = MintInfoResult( + name = info.name, + description = info.description, + descriptionLong = info.descriptionLong, + pubkey = info.pubkey, + version = info.version?.let { MintVersionInfo(it.name, it.version) }, + motd = info.motd, + iconUrl = info.iconUrl, + contacts = info.contact?.map { MintContactInfo(it.method, it.info) } ?: emptyList() + ) + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error fetching mint info for $mintUrl: ${e.message}", e) + WalletResult.Failure(WalletError.MintUnreachable(mintUrl, cause = e)) + } + } + + // ======================================================================== + // Lifecycle + // ======================================================================== + + override fun isReady(): Boolean = wallet != null + + // ======================================================================== + // TemporaryMintWalletFactory Implementation + // ======================================================================== + + override suspend fun createTemporaryWallet(mintUrl: String): WalletResult { + return try { + val tempMnemonic = generateMnemonic() + val tempDb = WalletSqliteDatabase.newInMemory() + val config = WalletConfig(targetProofCount = 10u) + + val cdkWallet = CdkWallet( + mintUrl, + CurrencyUnit.Sat, + tempMnemonic, + tempDb, + config + ) + + Log.d(TAG, "Created temporary wallet for mint $mintUrl") + WalletResult.Success(CdkTemporaryMintWallet(mintUrl, cdkWallet)) + } catch (e: Exception) { + Log.e(TAG, "Error creating temporary wallet for $mintUrl: ${e.message}", e) + WalletResult.Failure(WalletError.MintUnreachable(mintUrl, cause = e)) + } + } + + // ======================================================================== + // Helper Functions + // ======================================================================== + + private fun mapQuoteState(state: CdkQuoteState): QuoteStatus = when (state) { + CdkQuoteState.UNPAID -> QuoteStatus.UNPAID + CdkQuoteState.PENDING -> QuoteStatus.PENDING + CdkQuoteState.PAID -> QuoteStatus.PAID + CdkQuoteState.ISSUED -> QuoteStatus.ISSUED + else -> QuoteStatus.UNKNOWN + } + + private fun mapException( + e: Exception, + mintUrl: String? = null, + quoteId: String? = null + ): WalletError { + val message = e.message?.lowercase() ?: "" + return when { + message.contains("not found") && quoteId != null -> + WalletError.QuoteNotFound(quoteId) + message.contains("expired") && quoteId != null -> + WalletError.QuoteExpired(quoteId) + message.contains("insufficient") -> + WalletError.InsufficientBalance(Satoshis.ZERO, Satoshis.ZERO) + message.contains("network") || message.contains("connection") || message.contains("timeout") -> + WalletError.NetworkError(e.message ?: "Network error", e) + mintUrl != null && (message.contains("unreachable") || message.contains("failed to connect")) -> + WalletError.MintUnreachable(mintUrl, cause = e) + else -> + WalletError.Unknown(e.message ?: "Unknown error", e) + } + } +} + +/** + * CDK-based implementation of TemporaryMintWallet. + */ +internal class CdkTemporaryMintWallet( + override val mintUrl: String, + private val cdkWallet: CdkWallet +) : TemporaryMintWallet { + + companion object { + private const val TAG = "CdkTempMintWallet" + } + + override suspend fun refreshKeysets(): WalletResult> { + return try { + val keysets = cdkWallet.refreshKeysets() + val result = keysets.map { keyset -> + KeysetInfo( + id = keyset.id.toString(), + active = keyset.active, + unit = keyset.unit.toString().lowercase() + ) + } + Log.d(TAG, "Refreshed ${result.size} keysets from $mintUrl") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error refreshing keysets: ${e.message}", e) + WalletResult.Failure(WalletError.MintUnreachable(mintUrl, cause = e)) + } + } + + override suspend fun requestMeltQuote(bolt11Invoice: String): WalletResult { + return try { + Log.d(TAG, "Requesting melt quote from $mintUrl") + val quote = cdkWallet.meltQuote(PaymentMethod.Bolt11, bolt11Invoice, null, null) + + val result = MeltQuoteResult( + quoteId = quote.id, + amount = Satoshis(quote.amount.value.toLong()), + feeReserve = Satoshis(quote.feeReserve.value.toLong()), + status = mapQuoteState(quote.state), + expiryTimestamp = quote.expiry?.toLong() + ) + Log.d(TAG, "Melt quote: id=${quote.id}, amount=${result.amount.value}, feeReserve=${result.feeReserve.value}") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error requesting melt quote: ${e.message}", e) + WalletResult.Failure(WalletError.Unknown(e.message ?: "Melt quote failed", e)) + } + } + + override suspend fun meltWithToken( + quoteId: String, + encodedToken: String + ): WalletResult { + return try { + val cdkToken = CdkToken.decode(encodedToken) + + // Receive token into this wallet so its proofs are available for melt + val receiveOptions = ReceiveOptions( + amountSplitTarget = SplitTarget.None, + p2pkSigningKeys = emptyList(), + preimages = emptyList(), + metadata = emptyMap() + ) + cdkWallet.receive(cdkToken, receiveOptions) + + Log.d(TAG, "Executing melt for quote $quoteId") + val prepared = cdkWallet.prepareMelt(quoteId) + val finalized = prepared.confirm() + + val result = MeltResult( + success = finalized.state == org.cashudevkit.QuoteState.PAID, + status = mapQuoteState(finalized.state), + feePaid = Satoshis(finalized.feePaid?.value?.toLong() ?: 0L), + preimage = finalized.preimage, + changeProofsCount = finalized.change?.size ?: 0 + ) + Log.d(TAG, "Melt result: success=${result.success}, state=${result.status}") + WalletResult.Success(result) + } catch (e: Exception) { + Log.e(TAG, "Error executing melt: ${e.message}", e) + WalletResult.Failure(WalletError.MeltFailed(quoteId, e.message ?: "Melt failed", e)) + } + } + + override fun close() { + try { + cdkWallet.close() + Log.d(TAG, "Temporary wallet closed for $mintUrl") + } catch (e: Exception) { + Log.w(TAG, "Error closing temporary wallet: ${e.message}", e) + } + } + + private fun mapQuoteState(state: org.cashudevkit.QuoteState): QuoteStatus = when (state) { + org.cashudevkit.QuoteState.UNPAID -> QuoteStatus.UNPAID + org.cashudevkit.QuoteState.PENDING -> QuoteStatus.PENDING + org.cashudevkit.QuoteState.PAID -> QuoteStatus.PAID + org.cashudevkit.QuoteState.ISSUED -> QuoteStatus.ISSUED + else -> QuoteStatus.UNKNOWN + } +} diff --git a/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt index 77a16dcc..39b26002 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt @@ -1,9 +1,7 @@ package com.electricdreams.numo.feature.history -import android.app.Activity import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.view.View @@ -83,8 +81,18 @@ class PaymentsHistoryActivity : AppCompatActivity() { private fun handleEntryClick(entry: PaymentHistoryEntry, position: Int) { if (entry.isPending()) { - // Resume the pending payment - resumePendingPayment(entry) + // Only resume if entry has actual resume data (Lightning quote or Nostr profile). + // BTCPay pending entries have neither — resuming them would create a brand-new + // invoice rather than continuing the original one, so just show details instead. + val hasResumeData = entry.lightningQuoteId != null || entry.nostrNprofile != null + if (hasResumeData) { + resumePendingPayment(entry) + } else { + showTransactionDetails(entry, position) + } + } else if (entry.isExpired()) { + // Expired payments cannot be resumed — just show details + showTransactionDetails(entry, position) } else { // Show transaction details showTransactionDetails(entry, position) @@ -142,6 +150,10 @@ class PaymentsHistoryActivity : AppCompatActivity() { } private fun loadHistory() { + // Stale BTCPay pending entries (no resume data) will never be resolved by polling + // if the app was killed mid-flow — expire them now so they don't sit as "Pending" forever. + expireStaleBtcPayEntries() + val history = getPaymentHistory().toMutableList() Collections.reverse(history) // Show newest first adapter.setEntries(history) @@ -170,6 +182,12 @@ class PaymentsHistoryActivity : AppCompatActivity() { } } + private fun expireStaleBtcPayEntries() { + val history = getPaymentHistory(this).toMutableList() + val stale = history.filter { it.isPending() && it.lightningQuoteId == null && it.nostrNprofile == null } + stale.forEach { markPaymentExpired(this, it.id) } + } + private fun getPaymentHistory(): List = getPaymentHistory(this) companion object { @@ -246,7 +264,8 @@ class PaymentsHistoryActivity : AppCompatActivity() { lightningMintUrl: String? = null, ) { val history = getPaymentHistory(context).toMutableList() - val index = history.indexOfFirst { it.id == paymentId } + // Only complete entries that are still pending — never overwrite expired/cancelled status + val index = history.indexOfFirst { it.id == paymentId && it.isPending() } if (index >= 0) { val existing = history[index] @@ -424,6 +443,48 @@ class PaymentsHistoryActivity : AppCompatActivity() { } } + /** + * Mark a pending payment as expired (BTCPay invoice expired before payment). + * Keeps the entry in history unlike [cancelPendingPayment]. + */ + @JvmStatic + fun markPaymentExpired(context: Context, paymentId: String) { + val history = getPaymentHistory(context).toMutableList() + val index = history.indexOfFirst { it.id == paymentId && it.isPending() } + if (index == -1) return + + val existing = history[index] + val updated = PaymentHistoryEntry( + id = existing.id, + token = existing.token, + amount = existing.amount, + date = existing.date, + rawUnit = existing.getUnit(), + rawEntryUnit = existing.getEntryUnit(), + enteredAmount = existing.enteredAmount, + bitcoinPrice = existing.bitcoinPrice, + mintUrl = existing.mintUrl, + paymentRequest = existing.paymentRequest, + rawStatus = PaymentHistoryEntry.STATUS_EXPIRED, + paymentType = existing.paymentType, + lightningInvoice = existing.lightningInvoice, + lightningQuoteId = existing.lightningQuoteId, + lightningMintUrl = existing.lightningMintUrl, + formattedAmount = existing.formattedAmount, + nostrNprofile = existing.nostrNprofile, + nostrSecretHex = existing.nostrSecretHex, + checkoutBasketJson = existing.checkoutBasketJson, + basketId = existing.basketId, + tipAmountSats = existing.tipAmountSats, + tipPercentage = existing.tipPercentage, + swapToLightningMintJson = existing.swapToLightningMintJson, + ) + history[index] = updated + + val prefs = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + prefs.edit().putString(KEY_HISTORY, Gson().toJson(history)).apply() + } + /** * Cancel a pending payment (mark as cancelled or delete). */ diff --git a/app/src/main/java/com/electricdreams/numo/feature/settings/BtcPaySettingsActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/settings/BtcPaySettingsActivity.kt new file mode 100644 index 00000000..23bc5b0c --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/feature/settings/BtcPaySettingsActivity.kt @@ -0,0 +1,147 @@ +package com.electricdreams.numo.feature.settings + +import android.os.Bundle +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.electricdreams.numo.R +import com.electricdreams.numo.core.prefs.PreferenceStore +import com.electricdreams.numo.feature.enableEdgeToEdgeWithPill +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +class BtcPaySettingsActivity : AppCompatActivity() { + + private lateinit var enableSwitch: SwitchCompat + private lateinit var serverUrlInput: EditText + private lateinit var apiKeyInput: EditText + private lateinit var storeIdInput: EditText + private lateinit var testConnectionStatus: TextView + + companion object { + private const val KEY_ENABLED = "btcpay_enabled" + private const val KEY_SERVER_URL = "btcpay_server_url" + private const val KEY_API_KEY = "btcpay_api_key" + private const val KEY_STORE_ID = "btcpay_store_id" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_btcpay_settings) + + enableEdgeToEdgeWithPill(this, lightNavIcons = true) + + initViews() + setupListeners() + loadSettings() + } + + private fun initViews() { + findViewById(R.id.back_button).setOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + enableSwitch = findViewById(R.id.btcpay_enable_switch) + serverUrlInput = findViewById(R.id.btcpay_server_url_input) + apiKeyInput = findViewById(R.id.btcpay_api_key_input) + storeIdInput = findViewById(R.id.btcpay_store_id_input) + testConnectionStatus = findViewById(R.id.test_connection_status) + } + + private fun setupListeners() { + val enableToggleRow = findViewById(R.id.enable_toggle_row) + enableToggleRow.setOnClickListener { + enableSwitch.toggle() + } + + enableSwitch.setOnCheckedChangeListener { _, isChecked -> + PreferenceStore.app(this).putBoolean(KEY_ENABLED, isChecked) + } + + findViewById(R.id.test_connection_row).setOnClickListener { + testConnection() + } + } + + private fun loadSettings() { + val prefs = PreferenceStore.app(this) + enableSwitch.isChecked = prefs.getBoolean(KEY_ENABLED, false) + serverUrlInput.setText(prefs.getString(KEY_SERVER_URL, "") ?: "") + apiKeyInput.setText(prefs.getString(KEY_API_KEY, "") ?: "") + storeIdInput.setText(prefs.getString(KEY_STORE_ID, "") ?: "") + } + + private fun saveTextFields() { + val prefs = PreferenceStore.app(this) + prefs.putString(KEY_SERVER_URL, serverUrlInput.text.toString().trim()) + prefs.putString(KEY_API_KEY, apiKeyInput.text.toString().trim()) + prefs.putString(KEY_STORE_ID, storeIdInput.text.toString().trim()) + } + + private fun testConnection() { + val serverUrl = serverUrlInput.text.toString().trim().trimEnd('/') + val apiKey = apiKeyInput.text.toString().trim() + val storeId = storeIdInput.text.toString().trim() + + if (serverUrl.isBlank() || apiKey.isBlank() || storeId.isBlank()) { + testConnectionStatus.text = getString(R.string.btcpay_test_fill_all_fields) + testConnectionStatus.setTextColor(ContextCompat.getColor(this, R.color.color_error)) + return + } + + testConnectionStatus.text = getString(R.string.btcpay_test_connecting) + testConnectionStatus.setTextColor(ContextCompat.getColor(this, R.color.color_text_secondary)) + + lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + try { + val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + val request = Request.Builder() + .url("$serverUrl/api/v1/stores/$storeId/invoices") + .header("Authorization", "token $apiKey") + .get() + .build() + + val response = client.newCall(request).execute() + val code = response.code + response.close() + + if (code in 200..299) { + Result.success(Unit) + } else { + Result.failure(Exception("HTTP $code")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + if (result.isSuccess) { + testConnectionStatus.text = getString(R.string.btcpay_test_success) + testConnectionStatus.setTextColor(ContextCompat.getColor(this@BtcPaySettingsActivity, R.color.color_success_green)) + } else { + val error = result.exceptionOrNull()?.message ?: getString(R.string.btcpay_test_unknown_error) + testConnectionStatus.text = getString(R.string.btcpay_test_failed, error) + testConnectionStatus.setTextColor(ContextCompat.getColor(this@BtcPaySettingsActivity, R.color.color_error)) + } + } + } + + override fun onPause() { + super.onPause() + saveTextFields() + } +} diff --git a/app/src/main/java/com/electricdreams/numo/feature/settings/SettingsActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/settings/SettingsActivity.kt index 3ca32ab7..e008faaf 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/settings/SettingsActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/settings/SettingsActivity.kt @@ -14,6 +14,7 @@ import com.electricdreams.numo.feature.enableEdgeToEdgeWithPill import com.electricdreams.numo.feature.tips.TipsSettingsActivity import com.electricdreams.numo.feature.baskets.BasketNamesSettingsActivity import com.electricdreams.numo.feature.autowithdraw.AutoWithdrawSettingsActivity +import com.electricdreams.numo.core.prefs.PreferenceStore /** * Main Settings screen. @@ -48,10 +49,36 @@ class SettingsActivity : AppCompatActivity() { super.onResume() // Update developer section visibility when returning from About updateDeveloperSectionVisibility() + // Update mints/withdrawals availability based on BTCPay state + updateBtcPayDependentItems() } private fun setupViews() { updateDeveloperSectionVisibility() + updateBtcPayDependentItems() + } + + private fun updateBtcPayDependentItems() { + val btcPayEnabled = PreferenceStore.app(this).getBoolean("btcpay_enabled", false) + + val mintsItem = findViewById(R.id.mints_settings_item) + val withdrawalsItem = findViewById(R.id.withdrawals_settings_item) + + if (btcPayEnabled) { + mintsItem.alpha = 0.4f + mintsItem.isEnabled = false + mintsItem.setOnClickListener(null) + withdrawalsItem.alpha = 0.4f + withdrawalsItem.isEnabled = false + withdrawalsItem.setOnClickListener(null) + } else { + mintsItem.alpha = 1f + mintsItem.isEnabled = true + mintsItem.setOnClickListener { openProtectedActivity(MintsSettingsActivity::class.java) } + withdrawalsItem.alpha = 1f + withdrawalsItem.isEnabled = true + withdrawalsItem.setOnClickListener { openProtectedActivity(AutoWithdrawSettingsActivity::class.java) } + } } private fun updateDeveloperSectionVisibility() { @@ -89,18 +116,15 @@ class SettingsActivity : AppCompatActivity() { startActivity(Intent(this, CurrencySettingsActivity::class.java)) } - // Mints - protected (can withdraw funds) - findViewById(R.id.mints_settings_item).setOnClickListener { - openProtectedActivity(MintsSettingsActivity::class.java) - } + // Mints and Withdrawals listeners are managed by updateBtcPayDependentItems() findViewById(R.id.webhooks_settings_item).setOnClickListener { openProtectedActivity(WebhookSettingsActivity::class.java) } - // Withdrawals - protected (handles funds) - findViewById(R.id.withdrawals_settings_item).setOnClickListener { - openProtectedActivity(AutoWithdrawSettingsActivity::class.java) + // BTCPay Server - protected (holds API key) + findViewById(R.id.btcpay_settings_item).setOnClickListener { + openProtectedActivity(BtcPaySettingsActivity::class.java) } // === Security Section === diff --git a/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt b/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt index 7e6ea268..9254d85b 100644 --- a/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt +++ b/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt @@ -151,6 +151,63 @@ object CashuPaymentHelper { } } + /** + * Parse a NUT-18 Payment Request, remove any transport methods (making it suitable for NFC/HCE), + * and return the re-encoded string. + */ + @JvmStatic + fun stripTransports(paymentRequest: String): String? { + return try { + val decoded = PaymentRequest.decode(paymentRequest) + // Clear transports + decoded.transport = Optional.empty() + decoded.encode() + } catch (e: Exception) { + Log.e(TAG, "Error stripping transports from payment request: ${e.message}", e) + null + } + } + + /** + * Parse a NUT-18 Payment Request and extract the 'post' transport URL if available. + */ + @JvmStatic + fun getPostUrl(paymentRequest: String): String? { + return try { + val decoded = PaymentRequest.decode(paymentRequest) + if (decoded.transport.isPresent) { + val transports = decoded.transport.get() + for (t in transports) { + if (t.type.equals("post", ignoreCase = true)) { + return t.target + } + } + } + null + } catch (e: Exception) { + Log.e(TAG, "Error getting POST URL from payment request: ${e.message}", e) + null + } + } + + /** + * Parse a NUT-18 Payment Request and extract the ID. + */ + @JvmStatic + fun getId(paymentRequest: String): String? { + return try { + val decoded = PaymentRequest.decode(paymentRequest) + if (decoded.id.isPresent) { + decoded.id.get() + } else { + null + } + } catch (e: Exception) { + Log.e(TAG, "Error getting ID from payment request: ${e.message}", e) + null + } + } + // === Token helpers ===================================================== @JvmStatic diff --git a/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt b/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt index 7fc2756b..a2ad8fc0 100644 --- a/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt +++ b/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt @@ -31,6 +31,8 @@ class PaymentTabManager( fun onCashuTabSelected() } + enum class Tab { CASHU, LIGHTNING } + private var listener: TabSelectionListener? = null private var isLightningSelected = false @@ -93,6 +95,18 @@ class PaymentTabManager( listener?.onCashuTabSelected() } + /** + * Disable a tab (e.g. when BTCPay returns no cashuPR, disable [Tab.CASHU]). + * Greys it out, removes its click listener, and auto-selects the other tab. + */ + fun disableTab(tab: Tab) { + val view = if (tab == Tab.CASHU) cashuTab else lightningTab + view.isEnabled = false + view.alpha = 0.35f + view.setOnClickListener(null) + if (tab == Tab.CASHU) selectLightningTab() else selectCashuTab() + } + /** * Check if Lightning tab is currently visible/selected. */ diff --git a/app/src/main/java/com/electricdreams/numo/ui/adapter/PaymentsHistoryAdapter.kt b/app/src/main/java/com/electricdreams/numo/ui/adapter/PaymentsHistoryAdapter.kt index 4ce24d81..30dbdeb6 100644 --- a/app/src/main/java/com/electricdreams/numo/ui/adapter/PaymentsHistoryAdapter.kt +++ b/app/src/main/java/com/electricdreams/numo/ui/adapter/PaymentsHistoryAdapter.kt @@ -56,10 +56,12 @@ class PaymentsHistoryAdapter : RecyclerView.Adapter= 0) { "+$formattedAmount" } else { @@ -73,6 +75,7 @@ class PaymentsHistoryAdapter : RecyclerView.Adapter context.getString(R.string.history_row_title_pending_payment) + isExpired -> context.getString(R.string.history_row_title_pending_payment) entry.isLightning() -> context.getString(R.string.history_row_title_lightning_payment) entry.isCashu() -> context.getString(R.string.history_row_title_cashu_payment) entry.amount > 0 -> context.getString(R.string.history_row_title_cash_in) @@ -81,34 +84,47 @@ class PaymentsHistoryAdapter : RecyclerView.Adapter R.drawable.ic_pending + isPending || isExpired -> R.drawable.ic_pending entry.isLightning() -> R.drawable.ic_lightning_bolt else -> R.drawable.ic_bitcoin } holder.icon.setImageResource(iconRes) // Set icon tint based on status - val iconTint = if (isPending) { - context.getColor(R.color.color_warning) - } else { - context.getColor(R.color.color_text_primary) + val iconTint = when { + isPending -> context.getColor(R.color.color_warning) + isExpired -> context.getColor(R.color.color_error) + else -> context.getColor(R.color.color_text_primary) } holder.icon.setColorFilter(iconTint) - // Show status for pending payments - if (isPending) { - holder.statusText.visibility = View.VISIBLE - holder.statusText.text = context.getString(R.string.history_row_status_tap_to_resume) - holder.statusText.setTextColor(context.getColor(R.color.color_warning)) - } else { - holder.statusText.visibility = View.GONE + // Show status label for pending/expired payments + when { + isPending -> { + holder.statusText.visibility = View.VISIBLE + holder.statusText.text = context.getString(R.string.history_row_status_tap_to_resume) + holder.statusText.setTextColor(context.getColor(R.color.color_warning)) + } + isExpired -> { + holder.statusText.visibility = View.VISIBLE + holder.statusText.text = context.getString(R.string.history_row_status_expired) + holder.statusText.setTextColor(context.getColor(R.color.color_error)) + } + else -> { + holder.statusText.visibility = View.GONE + } } // Hide subtitle (payment type already shown in title) holder.subtitleText.visibility = View.GONE - holder.itemView.setOnClickListener { - onItemClickListener?.onItemClick(entry, position) + if (isExpired) { + holder.itemView.setOnClickListener(null) + holder.itemView.isClickable = false + } else { + holder.itemView.setOnClickListener { + onItemClickListener?.onItemClick(entry, position) + } } } diff --git a/app/src/main/res/layout/activity_btcpay_settings.xml b/app/src/main/res/layout/activity_btcpay_settings.xml new file mode 100644 index 00000000..e7435616 --- /dev/null +++ b/app/src/main/res/layout/activity_btcpay_settings.xml @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_payment_request.xml b/app/src/main/res/layout/activity_payment_request.xml index c894fe4b..8a4b793a 100644 --- a/app/src/main/res/layout/activity_payment_request.xml +++ b/app/src/main/res/layout/activity_payment_request.xml @@ -171,8 +171,17 @@ android:adjustViewBounds="true" android:scaleType="fitCenter" /> + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdc876de..e49fe877 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,4 +821,25 @@ Copy Entry Clear Error Logs Clear all stored error logs? This cannot be undone. + + + BTCPay Server + BTCPay Server integration + BTCPay Server + Enable BTCPay Server integration + Connection + Server URL + https://btcpay.example.com + API Key + Enter your API key + Store ID + Enter your store ID + Test + Test Connection + Verify your server settings + Connecting... + Connected successfully + Connection failed: %1$s + Please fill in all fields + Unknown error diff --git a/app/src/main/res/values/strings_history.xml b/app/src/main/res/values/strings_history.xml index ce293fdf..e2fd8710 100644 --- a/app/src/main/res/values/strings_history.xml +++ b/app/src/main/res/values/strings_history.xml @@ -10,6 +10,7 @@ Cash In Cash Out Tap to resume + Expired Open payment with... diff --git a/app/src/test/java/com/electricdreams/numo/core/payment/PaymentServiceFactoryTest.kt b/app/src/test/java/com/electricdreams/numo/core/payment/PaymentServiceFactoryTest.kt new file mode 100644 index 00000000..5614e458 --- /dev/null +++ b/app/src/test/java/com/electricdreams/numo/core/payment/PaymentServiceFactoryTest.kt @@ -0,0 +1,58 @@ +package com.electricdreams.numo.core.payment + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.electricdreams.numo.core.payment.impl.BTCPayPaymentService +import com.electricdreams.numo.core.payment.impl.LocalPaymentService +import com.electricdreams.numo.core.prefs.PreferenceStore +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PaymentServiceFactoryTest { + + private lateinit var context: Context + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + // Initialize singletons if needed + // CashuWalletManager.init(context) // Might need mocking if it does network or complex stuff + } + + @Test + fun `returns LocalPaymentService when btcpay is disabled`() { + val prefs = PreferenceStore.app(context) + prefs.putBoolean("btcpay_enabled", false) + + val service = PaymentServiceFactory.create(context) + assertTrue(service is LocalPaymentService) + } + + @Test + fun `returns BtcPayPaymentService when btcpay is enabled and config is valid`() { + val prefs = PreferenceStore.app(context) + prefs.putBoolean("btcpay_enabled", true) + prefs.putString("btcpay_server_url", "https://btcpay.example.com") + prefs.putString("btcpay_api_key", "secret-key") + prefs.putString("btcpay_store_id", "store-id") + + val service = PaymentServiceFactory.create(context) + assertTrue(service is BTCPayPaymentService) + } + + @Test + fun `falls back to LocalPaymentService when btcpay is enabled but config is missing`() { + val prefs = PreferenceStore.app(context) + prefs.putBoolean("btcpay_enabled", true) + prefs.putString("btcpay_server_url", "") // Missing URL + + val service = PaymentServiceFactory.create(context) + assertTrue("Should fallback if URL is empty", service is LocalPaymentService) + } +} diff --git a/app/src/test/java/com/electricdreams/numo/core/payment/impl/BtcPayPaymentServiceIntegrationTest.kt b/app/src/test/java/com/electricdreams/numo/core/payment/impl/BtcPayPaymentServiceIntegrationTest.kt new file mode 100644 index 00000000..93d2d19b --- /dev/null +++ b/app/src/test/java/com/electricdreams/numo/core/payment/impl/BtcPayPaymentServiceIntegrationTest.kt @@ -0,0 +1,340 @@ +package com.electricdreams.numo.core.payment.impl + +import com.electricdreams.numo.core.payment.BTCPayConfig +import com.electricdreams.numo.core.payment.PaymentState +import com.electricdreams.numo.core.wallet.WalletResult +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.io.FileInputStream +import java.util.Properties + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BtcPayPaymentServiceIntegrationTest { + + private lateinit var config: BTCPayConfig + private lateinit var service: BTCPayPaymentService + private val httpClient = OkHttpClient() + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + @Before + fun setup() { + val props = Properties() + var envFile = File("btcpay_env.properties") + if (!envFile.exists()) envFile = File("../btcpay_env.properties") + + if (envFile.exists()) { + println("Loading config from ${envFile.absolutePath}") + props.load(FileInputStream(envFile)) + config = BTCPayConfig( + serverUrl = props.getProperty("BTCPAY_SERVER_URL"), + apiKey = props.getProperty("BTCPAY_API_KEY"), + storeId = props.getProperty("BTCPAY_STORE_ID"), + ) + } else { + config = BTCPayConfig( + serverUrl = System.getenv("BTCPAY_SERVER_URL") ?: "http://localhost:49392", + apiKey = System.getenv("BTCPAY_API_KEY") ?: "", + storeId = System.getenv("BTCPAY_STORE_ID") ?: "", + ) + } + service = BTCPayPaymentService(config) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun skipIfNotConfigured(): Boolean { + if (config.apiKey.isEmpty() || config.storeId.isEmpty()) { + println("SKIP: BTCPay not configured") + return true + } + return false + } + + /** + * Uses the BTCPay Greenfield API to force an invoice into a specific status. + * Requires btcpay.store.canmodifystoresettings permission. + * Supported values: "Settled", "Invalid" + */ + private fun markInvoiceStatus(invoiceId: String, status: String) { + val url = "${config.serverUrl.trimEnd('/')}/api/v1/stores/${config.storeId}/invoices/$invoiceId/status" + val body = """{"status": "$status"}""" + val request = Request.Builder() + .url(url) + .post(body.toRequestBody(jsonMediaType)) + .addHeader("Authorization", "token ${config.apiKey}") + .build() + httpClient.newCall(request).execute().use { response -> + println("markInvoiceStatus($invoiceId, $status) → HTTP ${response.code}") + check(response.isSuccessful) { "markInvoiceStatus failed: HTTP ${response.code} ${response.body?.string()}" + } + } + } + + private fun createInvoiceId(amountSats: Long = 1000L, description: String = "Test"): String { + return service.let { + runBlocking { it.createPayment(amountSats, description).getOrThrow().paymentId } + } + } + + // ------------------------------------------------------------------------- + // isReady() + // ------------------------------------------------------------------------- + + @Test + fun testIsReady_withValidConfig() { + if (skipIfNotConfigured()) return + assertTrue("isReady() should return true with valid config", service.isReady()) + } + + @Test + fun testIsReady_withBlankUrl() { + assertFalse( + "isReady() should return false when URL is blank", + BTCPayPaymentService(BTCPayConfig("", "key", "store")).isReady() + ) + } + + @Test + fun testIsReady_withBlankApiKey() { + assertFalse( + "isReady() should return false when API key is blank", + BTCPayPaymentService(BTCPayConfig("http://localhost", "", "store")).isReady() + ) + } + + @Test + fun testIsReady_withBlankStoreId() { + assertFalse( + "isReady() should return false when store ID is blank", + BTCPayPaymentService(BTCPayConfig("http://localhost", "key", "")).isReady() + ) + } + + // ------------------------------------------------------------------------- + // createPayment() + // ------------------------------------------------------------------------- + + @Test + fun testCreatePayment_returnsPaymentId() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val result = service.createPayment(1000L, "Create test") + assertTrue("createPayment() should succeed", result is WalletResult.Success) + val data = result.getOrThrow() + assertTrue("paymentId should not be blank", data.paymentId.isNotBlank()) + println("Created invoice: ${data.paymentId}") + } + + @Test + fun testCreatePayment_returnsLightningBolt11() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val data = service.createPayment(500L, "Lightning test").getOrThrow() + assertNotNull("bolt11 should not be null", data.bolt11) + assertTrue( + "bolt11 should be a valid Lightning invoice", + data.bolt11!!.startsWith("lnbc") || + data.bolt11.startsWith("lntb") || + data.bolt11.startsWith("lnbcrt"), + ) + println("bolt11: ${data.bolt11!!.take(40)}...") + } + + @Test + fun testCreatePayment_returnsCashuPaymentRequest() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val data = service.createPayment(1000L, "Cashu test").getOrThrow() + // cashuPR may be null if Cashu plugin is not installed — just log the result + println("cashuPR: ${data.cashuPR?.take(40) ?: "null (Cashu plugin may not be configured)"}") + } + + @Test + fun testCreatePayment_withNullDescription() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val result = service.createPayment(1000L, null) + assertTrue("createPayment() with null description should succeed", result is WalletResult.Success) + } + + @Test + fun testCreatePayment_withSmallAmount() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val result = service.createPayment(1L, "1 sat test") + assertTrue("createPayment() with 1 sat should succeed", result is WalletResult.Success) + println("1-sat invoice: ${result.getOrThrow().paymentId}") + } + + @Test + fun testCreatePayment_withLargeAmount() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val result = service.createPayment(21_000L, "21k sat test") + assertTrue("createPayment() with large amount should succeed", result is WalletResult.Success) + } + + @Test + fun testCreatePayment_withInvalidApiKey_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val badService = BTCPayPaymentService(BTCPayConfig(config.serverUrl, "bad_api_key_xxx", config.storeId)) + val result = badService.createPayment(1000L, "Bad key test") + assertTrue("createPayment() with invalid API key should fail", result is WalletResult.Failure) + println("Expected error: ${(result as WalletResult.Failure).error.message}") + } + + @Test + fun testCreatePayment_withInvalidStoreId_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val badService = BTCPayPaymentService(BTCPayConfig(config.serverUrl, config.apiKey, "nonexistent_store_id")) + val result = badService.createPayment(1000L, "Bad store test") + assertTrue("createPayment() with invalid store ID should fail", result is WalletResult.Failure) + println("Expected error: ${(result as WalletResult.Failure).error.message}") + } + + // ------------------------------------------------------------------------- + // checkPaymentStatus() + // ------------------------------------------------------------------------- + + @Test + fun testCheckPaymentStatus_newInvoice_isPending() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "Status pending test") + val status = service.checkPaymentStatus(paymentId).getOrThrow() + assertEquals("Freshly created invoice should be PENDING", PaymentState.PENDING, status) + } + + @Test + fun testCheckPaymentStatus_afterMarkSettled_isPaid() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "Status settled test") + markInvoiceStatus(paymentId, "Settled") + val status = service.checkPaymentStatus(paymentId).getOrThrow() + assertEquals("Settled invoice should be PAID", PaymentState.PAID, status) + } + + @Test + fun testCheckPaymentStatus_afterMarkInvalid_isFailed() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "Status invalid test") + markInvoiceStatus(paymentId, "Invalid") + val status = service.checkPaymentStatus(paymentId).getOrThrow() + assertEquals("Invalidated invoice should be FAILED", PaymentState.FAILED, status) + } + + @Test + fun testCheckPaymentStatus_nonExistentId_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val result = service.checkPaymentStatus("nonexistent-invoice-id-xyz-12345") + assertTrue("Non-existent invoice ID should return failure", result is WalletResult.Failure) + println("Expected error: ${(result as WalletResult.Failure).error.message}") + } + + @Test + fun testCheckPaymentStatus_multipleConsecutivePolls_remainPending() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "Multi-poll test") + repeat(3) { i -> + val status = service.checkPaymentStatus(paymentId).getOrThrow() + assertEquals("Poll $i should still be PENDING", PaymentState.PENDING, status) + } + } + + // ------------------------------------------------------------------------- + // fetchLightningInvoice() + // ------------------------------------------------------------------------- + + @Test + fun testFetchLightningInvoice_returnsValidBolt11() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "LN fetch test") + val bolt11 = service.fetchLightningInvoice(paymentId) + assertNotNull("fetchLightningInvoice() should return a bolt11", bolt11) + assertTrue( + "bolt11 should be a valid Lightning invoice", + bolt11!!.startsWith("lnbc") || bolt11.startsWith("lntb") || bolt11.startsWith("lnbcrt"), + ) + println("Fetched bolt11: ${bolt11.take(40)}...") + } + + @Test + fun testFetchLightningInvoice_settledInvoice_doesNotThrow() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "LN fetch settled test") + markInvoiceStatus(paymentId, "Settled") + // Should not throw — result may be null or the original bolt11 + val bolt11 = service.fetchLightningInvoice(paymentId) + println("bolt11 for settled invoice: ${bolt11?.take(40) ?: "null"}") + } + + // ------------------------------------------------------------------------- + // redeemToken() + // ------------------------------------------------------------------------- + + @Test + fun testRedeemToken_invalidToken_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "Redeem test") + val result = service.redeemToken("cashuAinvalidtoken", paymentId) + assertTrue("redeemToken() with invalid token should fail", result is WalletResult.Failure) + println("Expected error: ${(result as WalletResult.Failure).error.message}") + } + + @Test + fun testRedeemToken_withoutPaymentId_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val result = service.redeemToken("cashuAinvalidtoken", null) + assertTrue("redeemToken() without paymentId should fail", result is WalletResult.Failure) + } + + @Test + fun testRedeemToken_emptyToken_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "Empty token test") + val result = service.redeemToken("", paymentId) + assertTrue("redeemToken() with empty token should fail", result is WalletResult.Failure) + } + + // ------------------------------------------------------------------------- + // redeemTokenToPostEndpoint() — NUT-18 + // ------------------------------------------------------------------------- + + @Test + fun testRedeemTokenToPostEndpoint_invalidToken_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "NUT-18 test") + val postUrl = "${config.serverUrl.trimEnd('/')}/cashu/pay-invoice" + val result = service.redeemTokenToPostEndpoint( + token = "cashuAinvalidtoken", + requestId = paymentId, + postUrl = postUrl, + ) + assertTrue("redeemTokenToPostEndpoint() with invalid token should fail", result is WalletResult.Failure) + println("Expected NUT-18 error: ${(result as WalletResult.Failure).error.message}") + } + + @Test + fun testRedeemTokenToPostEndpoint_settledInvoice_returnsFailure() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + val paymentId = createInvoiceId(1000L, "NUT-18 settled test") + markInvoiceStatus(paymentId, "Settled") + val postUrl = "${config.serverUrl.trimEnd('/')}/cashu/pay-invoice" + val result = service.redeemTokenToPostEndpoint( + token = "cashuAinvalidtoken", + requestId = paymentId, + postUrl = postUrl, + ) + // Settled invoice + invalid token = failure + assertTrue("NUT-18 redeem on settled invoice with invalid token should fail", result is WalletResult.Failure) + } +} diff --git a/app/src/test/java/com/electricdreams/numo/feature/settings/BtcPaySettingsActivityTest.kt b/app/src/test/java/com/electricdreams/numo/feature/settings/BtcPaySettingsActivityTest.kt new file mode 100644 index 00000000..9a34479e --- /dev/null +++ b/app/src/test/java/com/electricdreams/numo/feature/settings/BtcPaySettingsActivityTest.kt @@ -0,0 +1,49 @@ +package com.electricdreams.numo.feature.settings + +import android.widget.EditText +import androidx.appcompat.widget.SwitchCompat +import androidx.test.core.app.ActivityScenario +import com.electricdreams.numo.R +import com.electricdreams.numo.core.prefs.PreferenceStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BtcPaySettingsActivityTest { + + @Test + fun `loads and saves settings correctly`() { + val scenario = ActivityScenario.launch(BtcPaySettingsActivity::class.java) + + scenario.onActivity { activity -> + // Simulate user input + val serverUrlInput = activity.findViewById(R.id.btcpay_server_url_input) + val apiKeyInput = activity.findViewById(R.id.btcpay_api_key_input) + val storeIdInput = activity.findViewById(R.id.btcpay_store_id_input) + val enableSwitch = activity.findViewById(R.id.btcpay_enable_switch) + + // Set values + serverUrlInput.setText("https://test.btcpay.com") + apiKeyInput.setText("test-key") + storeIdInput.setText("test-store") + enableSwitch.isChecked = true + } + + // Trigger lifecycle to save (onPause) + scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED) + + scenario.onActivity { activity -> + // Verify prefs + val prefs = PreferenceStore.app(activity) + assertEquals("https://test.btcpay.com", prefs.getString("btcpay_server_url")) + assertEquals("test-key", prefs.getString("btcpay_api_key")) + assertEquals("test-store", prefs.getString("btcpay_store_id")) + assertTrue(prefs.getBoolean("btcpay_enabled", false)) + } + } +} diff --git a/app/src/test/java/com/electricdreams/numo/ndef/CashuPaymentHelperTest.kt b/app/src/test/java/com/electricdreams/numo/ndef/CashuPaymentHelperTest.kt index d50f5dd5..fbe31acc 100644 --- a/app/src/test/java/com/electricdreams/numo/ndef/CashuPaymentHelperTest.kt +++ b/app/src/test/java/com/electricdreams/numo/ndef/CashuPaymentHelperTest.kt @@ -3,6 +3,8 @@ package com.electricdreams.numo.ndef import com.electricdreams.numo.ndef.CashuPaymentHelper.extractCashuToken import com.electricdreams.numo.ndef.CashuPaymentHelper.isCashuPaymentRequest import com.electricdreams.numo.ndef.CashuPaymentHelper.isCashuToken + +import com.google.gson.JsonParser import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -66,4 +68,5 @@ class CashuPaymentHelperTest { assertNull(token) } + } diff --git a/integration-tests/btcpay/Dockerfile.builder b/integration-tests/btcpay/Dockerfile.builder new file mode 100644 index 00000000..6e175286 --- /dev/null +++ b/integration-tests/btcpay/Dockerfile.builder @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /source + +# Install git +RUN apt-get update && apt-get install -y git + +# Clone the repository +RUN git clone https://github.com/cashubtc/BTCNutServer.git . +RUN git submodule update --init --recursive + +# Build the plugin +WORKDIR /source/Plugin/BTCPayServer.Plugins.Cashu +RUN dotnet publish -c Release -o /output/BTCPayServer.Plugins.Cashu diff --git a/integration-tests/btcpay/docker-compose.yml b/integration-tests/btcpay/docker-compose.yml new file mode 100644 index 00000000..9ad242d9 --- /dev/null +++ b/integration-tests/btcpay/docker-compose.yml @@ -0,0 +1,131 @@ +version: "3" +services: + plugin-builder: + build: + context: . + dockerfile: Dockerfile.builder + volumes: + - plugin-data:/plugins + command: ["/bin/bash", "-c", "cp -r /output/BTCPayServer.Plugins.Cashu /plugins/"] + + btcpayserver: + image: btcpayserver/btcpayserver:2.2.1 + restart: unless-stopped + environment: + BTCPAY_NETWORK: "regtest" + BTCPAY_BIND: "0.0.0.0:49392" + BTCPAY_DATADIR: "/datadir" + BTCPAY_PLUGINDIR: "/datadir/Plugins" + BTCPAY_POSTGRES: "User ID=postgres;Password=postgres;Host=postgres;Port=5432;Database=btcpayserver" + BTCPAY_CHAINS: "btc" + BTCPAY_BTCEXPLORERURL: "http://nbxplorer:32838/" + BTCPAY_BTCLIGHTNING: "type=lnd-rest;server=http://lnd_bitcoin:8080/;allowinsecure=true" + BTCPAY_ENABLE_REGISTRATION: "true" + BTCPAY_DEBUGLOG: "/datadir/debug.log" + BTCPAY_DEBUGLOGLEVEL: "Debug" + ports: + - "49392:49392" + volumes: + - btcpay-data:/datadir + - plugin-data:/datadir/Plugins + depends_on: + plugin-builder: + condition: service_completed_successfully + postgres: + condition: service_started + nbxplorer: + condition: service_started + lnd_bitcoin: + condition: service_started + + lnd_bitcoin: + image: btcpayserver/lnd:v0.19.3-beta + restart: unless-stopped + environment: + LND_CHAIN: "btc" + LND_ENVIRONMENT: "regtest" + LND_EXPLORERURL: "http://nbxplorer:32838/" + LND_REST_LISTEN_HOST: "http://lnd_bitcoin:8080" + LND_EXTRA_ARGS: | + restlisten=lnd_bitcoin:8080 + rpclisten=127.0.0.1:10008 + rpclisten=lnd_bitcoin:10009 + bitcoin.node=bitcoind + bitcoind.rpchost=bitcoind:18443 + bitcoind.rpcuser=btcpay + bitcoind.rpcpass=btcpay + bitcoind.zmqpubrawblock=tcp://bitcoind:28332 + bitcoind.zmqpubrawtx=tcp://bitcoind:28333 + bitcoin.defaultchanconfs=1 + no-macaroons=1 + no-rest-tls=1 + noseedbackup=1 + volumes: + - lnd-bitcoin-data:/data + - bitcoind-data:/deps/.bitcoin + depends_on: + - bitcoind + - nbxplorer + + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: btcpayserver + volumes: + - postgres-data:/var/lib/postgresql/data + + nbxplorer: + image: nicolasdorier/nbxplorer:2.5.30 + restart: unless-stopped + environment: + NBXPLORER_NETWORK: "regtest" + NBXPLORER_CHAINS: "btc" + NBXPLORER_BIND: "0.0.0.0:32838" + NBXPLORER_POSTGRES: "User ID=postgres;Password=postgres;Host=postgres;Port=5432;Database=btcpayserver" + NBXPLORER_BTCRPCURL: "http://bitcoind:18443/" + NBXPLORER_BTCNODEENDPOINT: "bitcoind:39388" + NBXPLORER_BTCRPCUSER: "btcpay" + NBXPLORER_BTCRPCPASSWORD: "btcpay" + NBXPLORER_NOAUTH: 1 + NBXPLORER_EXPOSERPC: 1 + volumes: + - nbxplorer-data:/datadir + depends_on: + - postgres + - bitcoind + + bitcoind: + image: btcpayserver/bitcoin:26.0 + restart: unless-stopped + environment: + BITCOIN_NETWORK: "regtest" + BITCOIN_EXTRA_ARGS: |- + rpcport=18443 + rpcbind=0.0.0.0:18443 + rpcallowip=0.0.0.0/0 + rpcuser=btcpay + rpcpassword=btcpay + port=39388 + whitelist=0.0.0.0/0 + zmqpubrawblock=tcp://0.0.0.0:28332 + zmqpubrawtx=tcp://0.0.0.0:28333 + fallbackfee=0.0002 + ports: + - "18443:18443" + expose: + - "39388" + - "28332" + - "28333" + volumes: + - bitcoind-data:/data + +volumes: + btcpay-data: + postgres-data: + nbxplorer-data: + plugin-data: + bitcoind-data: + lnd-bitcoin-data: \ No newline at end of file diff --git a/integration-tests/btcpay/provision.sh b/integration-tests/btcpay/provision.sh new file mode 100755 index 00000000..82e3d139 --- /dev/null +++ b/integration-tests/btcpay/provision.sh @@ -0,0 +1,123 @@ +#!/bin/bash +set -e + +BASE_URL="http://localhost:49392" +EMAIL="admin@example.com" +PASSWORD="Password123!" + +echo "=== BTCPay Server Provisioning ===" + +# Wait for BTCPay Server to be ready +echo "Waiting for BTCPay Server..." +for i in {1..60}; do + if curl -s "$BASE_URL/health" > /dev/null 2>&1; then + echo "Server is ready." + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: BTCPay Server did not start within 120s" + exit 1 + fi + sleep 2 +done + +# Create admin user +echo "Creating user..." +RESPONSE=$(curl -s -X POST "$BASE_URL/api/v1/users" \ + -H "Content-Type: application/json" \ + -d "{\"email\": \"$EMAIL\", \"password\": \"$PASSWORD\", \"isAdministrator\": true}") +echo "User response: $RESPONSE" + +# Generate API Key +echo "Generating API Key..." +RESPONSE=$(curl -s -u "$EMAIL:$PASSWORD" -X POST "$BASE_URL/api/v1/api-keys" \ + -H "Content-Type: application/json" \ + -d '{ + "label": "IntegrationTestKey", + "permissions": [ + "btcpay.store.canmodifystoresettings", + "btcpay.store.cancreateinvoice", + "btcpay.store.canviewinvoices", + "btcpay.store.canmodifyinvoices", + "btcpay.user.canviewprofile", + "btcpay.server.canuseinternallightningnode" + ] + }') + +API_KEY=$(echo "$RESPONSE" | jq -r '.apiKey') +if [ -z "$API_KEY" ] || [ "$API_KEY" == "null" ]; then + echo "ERROR: Failed to generate API Key: $RESPONSE" + exit 1 +fi +echo "API Key: $API_KEY" + +# Create Store +echo "Creating Store..." +RESPONSE=$(curl -s -X POST "$BASE_URL/api/v1/stores" \ + -H "Authorization: token $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name": "IntegrationTestStore", "defaultCurrency": "SATS"}') + +STORE_ID=$(echo "$RESPONSE" | jq -r '.id') +if [ -z "$STORE_ID" ] || [ "$STORE_ID" == "null" ]; then + echo "ERROR: Failed to create store: $RESPONSE" + exit 1 +fi +echo "Store ID: $STORE_ID" + +echo "Enabling Lightning..." +for i in {1..30}; do + RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \ + "$BASE_URL/api/v1/stores/$STORE_ID/payment-methods/BTC-LN" \ + -H "Authorization: token $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"enabled": true, "config": "Internal Node"}') + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" == "200" ]; then + echo "Lightning enabled!" + break + fi + echo "Not ready (HTTP $HTTP_CODE): $BODY - retrying ($i/30)..." + sleep 5 +done + +# Wait for invoice creation readiness +echo "Waiting for invoice creation to be ready..." +for i in {1..30}; do + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + "$BASE_URL/api/v1/stores/$STORE_ID/invoices" \ + -H "Authorization: token $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"amount": "1000", "currency": "SATS"}') + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" == "200" ] || [ "$HTTP_CODE" == "201" ]; then + echo "Invoice creation ready (HTTP $HTTP_CODE)" + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Invoice creation not ready after 90s: $BODY" + exit 1 + fi + echo "Not ready yet (HTTP $HTTP_CODE), waiting 3s... ($i/30)" + sleep 3 +done + +# Output to properties file +OUTPUT_FILE="btcpay_env.properties" + +cat > "$OUTPUT_FILE" <