diff --git a/.github/workflows/btcpay-integration.yml b/.github/workflows/btcpay-integration.yml new file mode 100644 index 000000000..ae9bb274d --- /dev/null +++ b/.github/workflows/btcpay-integration.yml @@ -0,0 +1,86 @@ +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 + CDK mint stack + working-directory: integration-tests/btcpay + run: docker compose up -d + + - name: Wait for Lightning channel setup + working-directory: integration-tests/btcpay + run: | + echo "Waiting for channel-setup to complete (up to 300 s)..." + if ! timeout 300 docker compose wait channel-setup; then + echo "channel-setup failed or timed out" + docker compose logs channel-setup + docker compose logs mint_lnd + docker compose logs lnd_bitcoin + exit 1 + fi + echo "Channel setup complete." + + - name: Wait for CDK mint to be ready + run: | + echo "Waiting for CDK mint at localhost:3338..." + for i in $(seq 1 30); do + if curl -sf http://localhost:3338/v1/info >/dev/null 2>&1; then + echo "CDK mint ready." + curl -sf http://localhost:3338/v1/info | python3 -m json.tool + break + fi + sleep 3 + done + + - 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 BTCPay integration tests + run: | + chmod +x gradlew + ./gradlew testDebugUnitTest \ + --tests "com.electricdreams.numo.core.payment.impl.BtcPayPaymentServiceIntegrationTest.*" \ + --no-daemon --console=plain + + - name: Dump logs on failure + if: failure() + working-directory: integration-tests/btcpay + run: | + echo "=== cdk-mint ===" + docker compose logs cdk-mint | tail -100 + echo "=== mint_lnd ===" + docker compose logs mint_lnd | tail -50 + echo "=== customer_lnd ===" + docker compose logs customer_lnd | tail -50 + echo "=== channel-setup ===" + docker compose logs channel-setup + echo "=== btcpayserver ===" + docker compose logs btcpayserver | tail -100 + + - name: Cleanup + if: always() + working-directory: integration-tests/btcpay + run: docker compose down -v diff --git a/.gitignore b/.gitignore index bda94ab75..fd7c0ae70 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ release opencode.json .kotlin/ .opencode/ +btcpay_env.properties diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 44cccf423..1df40a887 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,7 @@ android:theme="@style/Theme.Numo" android:localeConfig="@xml/locales_config" android:name=".NumoApplication" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="33"> + + + + lightningHandler?.currentInvoice ?: lightningInvoice - PaymentTabManager.PaymentTab.CASHU -> nostrHandler?.paymentRequest + PaymentTabManager.PaymentTab.CASHU -> nostrHandler?.paymentRequest ?: btcPayCashuPR ?: hcePaymentRequest PaymentTabManager.PaymentTab.UNIFIED -> { val creq = nostrHandler?.paymentRequestBech32 val lnbc = lightningHandler?.currentInvoice ?: lightningInvoice @@ -373,6 +390,12 @@ class PaymentRequestActivity : AppCompatActivity() { // Initialize all payment modes (NDEF, Nostr, Lightning) initializePaymentRequest() + + // If resuming a local Lightning payment, auto-switch to Lightning tab. + // BTCPay resume uses resumeLightningQuoteId for the invoice ID — don't switch tab for it. + if (isResumingPayment && resumeLightningQuoteId != null && paymentService !is BTCPayPaymentService) { + tabManager.selectTab(PaymentTabManager.PaymentTab.LIGHTNING) + } } /** @@ -546,6 +569,215 @@ class PaymentRequestActivity : AppCompatActivity() { statusText.visibility = View.VISIBLE statusText.text = getString(R.string.payment_request_status_preparing) + // Create the payment service (BTCPay or Local) + paymentService = PaymentServiceFactory.create(this) + + val isBtcPay = paymentService is BTCPayPaymentService + + if (isBtcPay) { + val existingInvoiceId = if (isResumingPayment) resumeLightningQuoteId else null + if (existingInvoiceId != null) { + resumeBtcPayPaymentRequest(existingInvoiceId) + } else { + initializeBtcPayPaymentRequest() + } + } else { + initializeLocalPaymentRequest() + } + } + + /** + * BTCPay mode: create an invoice via BTCPay Server, display the bolt11 / + * cashu QR codes from the response, and poll for payment status. + */ + private fun initializeBtcPayPaymentRequest() { + // Show spinner, hide QR and logo while createPayment() runs + cashuQrImageView.visibility = View.INVISIBLE + cashuLogoCard.visibility = View.GONE + cashuLoadingSpinner.visibility = View.VISIBLE + + uiScope.launch { + val result = paymentService.createPayment(paymentAmount, "Payment of $paymentAmount sats") + result.onSuccess { payment -> + btcPayPaymentId = payment.paymentId + btcPayInvoiceCreatedAt = System.currentTimeMillis() + // Persist invoice ID so resume can reuse it instead of creating a new one + pendingPaymentId?.let { + PaymentsHistoryActivity.updatePendingWithLightningInfo( + context = this@PaymentRequestActivity, + paymentId = it, + lightningQuoteId = 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 + + 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) + lightningQrImageView.visibility = View.VISIBLE + 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 + updateUnifiedQrCode() + if (tabManager.getCurrentTab() == PaymentTabManager.PaymentTab.LIGHTNING) { + setHceToLightning() + } + } + + /** + * Resume a BTCPay payment using an existing invoice ID, avoiding creation of a new invoice. + */ + private fun resumeBtcPayPaymentRequest(invoiceId: String) { + cashuQrImageView.visibility = View.INVISIBLE + cashuLogoCard.visibility = View.GONE + cashuLoadingSpinner.visibility = View.VISIBLE + + val btcPay = paymentService as BTCPayPaymentService + uiScope.launch { + val result = btcPay.fetchExistingPaymentData(invoiceId) + result.onSuccess { payment -> + btcPayPaymentId = invoiceId + btcPayInvoiceCreatedAt = System.currentTimeMillis() + + val hasCashu = !payment.cashuPR.isNullOrBlank() + val hasLightning = !payment.bolt11.isNullOrBlank() + + if (!hasCashu && !hasLightning) { + cashuLoadingSpinner.visibility = View.GONE + handlePaymentError("BTCPay returned no payment methods") + return@onSuccess + } + + 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 on resume: ${e.message}", e) + } + cashuLoadingSpinner.visibility = View.GONE + hcePaymentRequest = CashuPaymentHelper.stripTransports(payment.cashuPR!!) ?: payment.cashuPR + if (NdefHostCardEmulationService.isHceAvailable(this@PaymentRequestActivity)) { + startService(Intent(this@PaymentRequestActivity, NdefHostCardEmulationService::class.java)) + setupNdefPayment() + } + } else { + cashuLoadingSpinner.visibility = View.GONE + tabManager.disableTab(PaymentTabManager.Tab.CASHU) + } + + if (hasLightning) { + showBtcPayLightningQr(payment.bolt11!!) + } else { + fetchBtcPayLightningInBackground(invoiceId) + } + + statusText.text = getString(R.string.payment_request_status_waiting_for_payment) + startBtcPayPolling(invoiceId) + }.onFailure { error -> + Log.e(TAG, "BTCPay resume failed: ${error.message}", error) + cashuLoadingSpinner.visibility = View.GONE + // Only create a new invoice if the original is definitively gone (404/expired). + // For network errors, show the error rather than risk duplicate invoices. + val isInvoiceGone = error is WalletError.NetworkError && + (error.message?.contains("404") == true || error.message?.contains("expired", ignoreCase = true) == true) + if (isInvoiceGone) { + Log.d(TAG, "Invoice gone, creating new BTCPay invoice") + initializeBtcPayPaymentRequest() + } else { + handlePaymentError(error.message ?: "Failed to load payment") + } + } + } + } + + 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) + return@launch + } + Log.d(TAG, "Background bolt11 fetch attempt $attempt — not ready yet") + } + // All attempts exhausted — hide spinner + Log.w(TAG, "Lightning invoice not available after all attempts") + lightningLoadingSpinner.visibility = View.GONE + } + } + + /** + * 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() @@ -602,6 +834,68 @@ 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) { + btcPayPollingJob = uiScope.launch { + var consecutiveErrors = 0 + var pollInterval = 2000L + + while (!hasTerminalOutcome) { + // Fix 7: local expiry guard (15 min) in case server never returns EXPIRED + if (btcPayInvoiceCreatedAt > 0 && + System.currentTimeMillis() - btcPayInvoiceCreatedAt > BTCPAY_INVOICE_TIMEOUT_MS) { + Log.w(TAG, "BTCPay invoice timed out locally after ${BTCPAY_INVOICE_TIMEOUT_MS / 60000} min") + btcPayPollingJob?.cancel() + pendingPaymentId?.let { PaymentsHistoryActivity.markPaymentExpired(this@PaymentRequestActivity, it) } + handlePaymentError("Invoice expired") + return@launch + } + + delay(pollInterval) + if (hasTerminalOutcome) break + + val statusResult = paymentService.checkPaymentStatus(paymentId) + statusResult.onSuccess { state -> + consecutiveErrors = 0 + pollInterval = 2000L + when (state) { + PaymentState.PAID -> { + btcPayPollingJob?.cancel() + val type = when (currentHceMode) { + HceMode.CASHU, HceMode.UNIFIED -> PaymentHistoryEntry.TYPE_CASHU + HceMode.LIGHTNING -> PaymentHistoryEntry.TYPE_LIGHTNING + } + handleLightningPaymentSuccess(type, btcPayInvoiceId = btcPayPaymentId) + } + PaymentState.EXPIRED -> { + btcPayPollingJob?.cancel() + pendingPaymentId?.let { PaymentsHistoryActivity.markPaymentExpired(this@PaymentRequestActivity, it) } + handlePaymentError("Invoice expired") + } + PaymentState.FAILED -> { + btcPayPollingJob?.cancel() + pendingPaymentId?.let { PaymentsHistoryActivity.markPaymentFailed(this@PaymentRequestActivity, it) } + handlePaymentError("Invoice invalid") + } + PaymentState.PENDING -> { /* continue */ } + } + }.onFailure { error -> + // Fix 6: exponential backoff, stop after too many consecutive errors + consecutiveErrors++ + pollInterval = minOf(pollInterval * 2, 30_000L) + Log.w(TAG, "BTCPay poll error ($consecutiveErrors): ${error.message}") + if (consecutiveErrors >= BTCPAY_MAX_POLL_ERRORS) { + Log.e(TAG, "BTCPay polling stopped after $consecutiveErrors consecutive errors") + btcPayPollingJob?.cancel() + handlePaymentError("Server unreachable") + } + } + } + } + } + private fun setHceToCashu() { val request = hcePaymentRequest ?: run { Log.w(TAG, "setHceToCashu() called but hcePaymentRequest is null") @@ -645,7 +939,8 @@ class PaymentRequestActivity : AppCompatActivity() { } private fun setHceToUnified() { - val creq = hcePaymentRequestBech32 + // In BTCPay mode hcePaymentRequestBech32 is not set; fall back to stripped cashuPR + val creq = hcePaymentRequestBech32 ?: hcePaymentRequest val lnbc = lightningInvoice if (creq == null && lnbc == null) { @@ -680,9 +975,10 @@ class PaymentRequestActivity : AppCompatActivity() { } private fun updateUnifiedQrCode() { - val creq = nostrHandler?.paymentRequestBech32 + val creq = nostrHandler?.paymentRequestBech32 ?: hcePaymentRequestBech32 ?: btcPayCashuPR ?: hcePaymentRequest val lnbc = lightningInvoice - + + // We only show the unified QR when BOTH Cashu and Lightning requests are ready // (unless lightning is explicitly disabled or errored out, but for simplicity we assume we need both if Lightning is supported) @@ -907,6 +1203,43 @@ 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(PaymentHistoryEntry.TYPE_CASHU) + } + }.onFailure { e -> + // Redemption failed on our side — check BTCPay invoice + // status to see if it was settled anyway (e.g. race + // condition). If still unpaid, reset and allow retry. + Log.w(TAG, "BTCPay NFC redemption failed: ${e.message} — checking invoice status") + val statusResult = paymentService.checkPaymentStatus(invoiceId) + statusResult.onSuccess { state -> + when (state) { + PaymentState.PAID -> withContext(Dispatchers.Main) { + handleLightningPaymentSuccess(PaymentHistoryEntry.TYPE_CASHU) + } + PaymentState.PENDING -> withContext(Dispatchers.Main) { + // Invoice still open — show error screen with retry option + handlePaymentError(e.message ?: "NFC payment failed") + } + else -> throw Exception("BTCPay redemption failed: ${e.message}") + } + }.onFailure { + 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, @@ -1042,7 +1375,10 @@ class PaymentRequestActivity : AppCompatActivity() { * history can record the payment (amount, date, etc.) but leave the * token field effectively blank. */ - private fun handleLightningPaymentSuccess() { + private fun handleLightningPaymentSuccess( + paymentType: String = PaymentHistoryEntry.TYPE_LIGHTNING, + btcPayInvoiceId: String? = null, + ) { // Guard against late callbacks so we don't surface a failure screen // after a successful Lightning payment has already been processed. if (!beginTerminalOutcome("lightning_success")) return @@ -1059,11 +1395,12 @@ class PaymentRequestActivity : AppCompatActivity() { context = this, paymentId = paymentId, token = "", - paymentType = PaymentHistoryEntry.TYPE_LIGHTNING, + paymentType = paymentType, mintUrl = lightningMintUrl, lightningInvoice = lightningInvoice, lightningQuoteId = lightningQuoteId, lightningMintUrl = lightningMintUrl, + btcPayInvoiceId = btcPayInvoiceId, ) } @@ -1159,6 +1496,10 @@ class PaymentRequestActivity : AppCompatActivity() { hasTerminalOutcome = true cancelNfcSafetyTimeout() + // Stop BTCPay polling + btcPayPollingJob?.cancel() + btcPayPollingJob = null + // Stop Nostr handler nostrHandler?.stop() nostrHandler = null @@ -1190,6 +1531,8 @@ class PaymentRequestActivity : AppCompatActivity() { override fun onDestroy() { cancelNfcSafetyTimeout() + btcPayPollingJob?.cancel() + btcPayPollingJob = null nostrHandler?.stop() nostrHandler = null lightningHandler?.cancel() @@ -1766,6 +2109,8 @@ class PaymentRequestActivity : AppCompatActivity() { companion object { private const val TAG = "PaymentRequestActivity" private const val NFC_READ_TIMEOUT_MS = 5_000L + private const val BTCPAY_INVOICE_TIMEOUT_MS = 15 * 60 * 1000L // 15 min + private const val BTCPAY_MAX_POLL_ERRORS = 5 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 c8732dd06..576a480d8 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 @@ -5,6 +5,9 @@ import android.util.Log import com.electricdreams.numo.core.util.BalanceRefreshBroadcast 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 @@ -246,6 +249,26 @@ object CashuWalletManager : MintManager.MintChangeListener { /** Current database instance, mostly for debugging or future use. */ fun getDatabase(): WalletStore? = 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/HistoryEntry.kt b/app/src/main/java/com/electricdreams/numo/core/data/model/HistoryEntry.kt index 5f5da1e05..d4bdd1977 100644 --- a/app/src/main/java/com/electricdreams/numo/core/data/model/HistoryEntry.kt +++ b/app/src/main/java/com/electricdreams/numo/core/data/model/HistoryEntry.kt @@ -20,4 +20,6 @@ interface HistoryEntry { fun getBaseAmountSats(): Long fun isPending(): Boolean fun isCompleted(): Boolean + fun isExpired(): Boolean = false + fun isFailed(): Boolean = false } 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 2806360e9..389d67dc8 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 @@ -99,6 +99,10 @@ data class PaymentHistoryEntry( @SerializedName("swapToLightningMintJson") val swapToLightningMintJson: String? = null, + /** BTCPay Server invoice ID - for resuming BTCPay pending payments */ + @SerializedName("btcPayInvoiceId") + val btcPayInvoiceId: String? = null, + /** User-assigned label for this transaction */ @SerializedName("label") override val label: String? = null, @@ -152,6 +156,10 @@ data class PaymentHistoryEntry( /** Check if this payment is completed */ override fun isCompleted(): Boolean = status == STATUS_COMPLETED + /** Check if this payment expired (BTCPay invoice expired before payment) */ + override fun isExpired(): Boolean = status == STATUS_EXPIRED + override fun isFailed(): Boolean = status == STATUS_FAILED + /** Check if this payment was via Lightning */ fun isLightning(): Boolean = paymentType == TYPE_LIGHTNING @@ -202,6 +210,8 @@ 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 STATUS_FAILED = "failed" 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 000000000..ad743b362 --- /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/IPaymentService.kt b/app/src/main/java/com/electricdreams/numo/core/payment/IPaymentService.kt new file mode 100644 index 000000000..440156ce8 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/IPaymentService.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 IPaymentService { + + /** + * 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 000000000..d8e319a45 --- /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 [IPaymentService] 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): IPaymentService { + 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 000000000..18b9b117a --- /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 000000000..c69eea06b --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/payment/impl/BTCPayPaymentService.kt @@ -0,0 +1,297 @@ +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.IPaymentService +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 + +/** + * [IPaymentService] 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 +) : IPaymentService { + + 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 = try { + JsonParser.parseString(body).asJsonObject + } catch (e: Exception) { + throw WalletError.NetworkError("BTCPay returned unexpected response format: ${body.take(100)}") + } + 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 + } + + /** + * Fetch payment data for an existing BTCPay invoice without creating a new one. + * Used when resuming a payment that already has a BTCPay invoice. + */ + suspend fun fetchExistingPaymentData(invoiceId: String): WalletResult = + withContext(Dispatchers.IO) { + WalletResult.runCatching { + val (bolt11, cashuPR) = fetchPaymentMethods(invoiceId) + PaymentData( + paymentId = invoiceId, + bolt11 = bolt11, + cashuPR = cashuPR, + mintUrl = null, + expiresAt = null + ) + } + } + + 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 = try { + JsonParser.parseString(body).asJsonObject + } catch (e: Exception) { + throw WalletError.NetworkError("BTCPay returned unexpected response format: ${body.take(100)}") + } + 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 = try { + JsonParser.parseString(body).asJsonArray + } catch (e: Exception) { + throw WalletError.NetworkError("BTCPay returned unexpected response format: ${body.take(100)}") + } + + 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 000000000..bfc8f66c0 --- /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.IPaymentService +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 + +/** + * [IPaymentService] 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 +) : IPaymentService { + + 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 000000000..a222a520c --- /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 000000000..3edb137d5 --- /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 000000000..436a753f6 --- /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 000000000..e471e4104 --- /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 000000000..4f5f8afb7 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/core/wallet/impl/CdkWalletProvider.kt @@ -0,0 +1,510 @@ +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.WalletStore +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 tempDbStore = WalletStore.Custom(tempDb) + val config = WalletConfig(targetProofCount = 10u) + + val cdkWallet = CdkWallet( + mintUrl, + CurrencyUnit.Sat, + tempMnemonic, + tempDbStore, + 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 b2b92fbab..79b93204b 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 @@ -5,7 +5,6 @@ import com.electricdreams.numo.core.util.BalanceRefreshBroadcast import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import com.electricdreams.numo.util.createProgressDialog @@ -25,6 +24,7 @@ import com.electricdreams.numo.core.cashu.CashuWalletManager import com.electricdreams.numo.core.data.model.HistoryEntry import com.electricdreams.numo.core.data.model.PaymentHistoryEntry import com.electricdreams.numo.core.model.Amount +import com.electricdreams.numo.core.prefs.PreferenceStore import com.electricdreams.numo.core.util.CurrencyManager import com.electricdreams.numo.core.worker.BitcoinPriceWorker import com.electricdreams.numo.databinding.ActivityHistoryBinding @@ -185,16 +185,18 @@ class PaymentsHistoryActivity : AppCompatActivity() { private fun handleEntryClick(entry: HistoryEntry, position: Int) { when (entry) { is PaymentHistoryEntry -> { - if (entry.isPending()) { - // Check if this is a pending swap-to-lightning-mint flow - if (entry.getSwapLightningQuoteId() != null) { - checkAndFinalizeSwap(entry) - } else { - // Resume the pending payment normally - resumePendingPayment(entry) + when { + entry.isExpired() -> showTransactionDetails(entry, position) + entry.isPending() -> { + when { + entry.getSwapLightningQuoteId() != null -> checkAndFinalizeSwap(entry) + // BTCPay pending entries have no lightning/nostr resume data — + // resuming would create a new invoice, so just show details. + entry.lightningQuoteId == null && entry.nostrNprofile == null -> showTransactionDetails(entry, position) + else -> resumePendingPayment(entry) + } } - } else { - showTransactionDetails(entry, position) + else -> showTransactionDetails(entry, position) } } is WithdrawHistoryEntry -> showTransactionDetails(entry, position) @@ -339,6 +341,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 paymentHistory: List = getPaymentHistory() val withdrawHistory: List = AutoWithdrawManager.getInstance(this) .getHistory() @@ -376,6 +382,35 @@ class PaymentsHistoryActivity : AppCompatActivity() { } } + /** + * Expire pending entries that can no longer be resolved: + * - Orphaned entries with no resume data + * - Entries older than [STALE_PENDING_THRESHOLD_MS] + * - BTCPay entries when BTCPay is disabled (they store the BTCPay invoice ID + * in [PaymentHistoryEntry.lightningQuoteId] but have no lightningMintUrl/nostrNprofile; + * without an active BTCPay connection these can never settle) + */ + private fun expireStaleBtcPayEntries() { + val history = getPaymentHistory(this).toMutableList() + val cutoff = System.currentTimeMillis() - STALE_PENDING_THRESHOLD_MS + val btcPayEnabled = PreferenceStore.app(this).getBoolean("btcpay_enabled", false) + + val stale = history.filter { entry -> + entry.isPending() && ( + // No resume data at all — always orphaned + (entry.lightningQuoteId == null && entry.nostrNprofile == null && entry.btcPayInvoiceId == null) || + // Has resume data but payment is too old to still be valid + entry.date.time < cutoff || + // BTCPay is disabled — pending BTCPay entries can never be resolved. + // BTCPay entries have lightningQuoteId (set to invoice ID) but no + // lightningMintUrl (local Lightning) or nostrNprofile (Nostr). + (!btcPayEnabled && entry.lightningQuoteId != null + && entry.lightningMintUrl == null && entry.nostrNprofile == null) + ) + } + stale.forEach { markPaymentExpired(this, it.id) } + } + private fun getPaymentHistory(): List = getPaymentHistory(this) companion object { @@ -383,6 +418,9 @@ class PaymentsHistoryActivity : AppCompatActivity() { private const val KEY_HISTORY = "history" private const val REQUEST_TRANSACTION_DETAIL = 1001 private const val REQUEST_RESUME_PAYMENT = 1002 + // Pending payments older than this are considered stale regardless of resume data. + // BTCPay invoices default to 15min; local Lightning quotes also expire. 2h is generous. + private const val STALE_PENDING_THRESHOLD_MS = 2 * 60 * 60 * 1000L @JvmStatic fun getPaymentHistory(context: Context): List { @@ -450,9 +488,11 @@ class PaymentsHistoryActivity : AppCompatActivity() { lightningInvoice: String? = null, lightningQuoteId: String? = null, lightningMintUrl: String? = null, + btcPayInvoiceId: 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] @@ -475,11 +515,12 @@ class PaymentsHistoryActivity : AppCompatActivity() { formattedAmount = existing.formattedAmount, nostrNprofile = existing.nostrNprofile, nostrSecretHex = existing.nostrSecretHex, - checkoutBasketJson = existing.checkoutBasketJson, // Preserve basket data - basketId = existing.basketId, // Preserve basket ID - tipAmountSats = existing.tipAmountSats, // Preserve tip info - tipPercentage = existing.tipPercentage, // Preserve tip info - label = existing.label, // Preserve label + checkoutBasketJson = existing.checkoutBasketJson, + basketId = existing.basketId, + tipAmountSats = existing.tipAmountSats, + tipPercentage = existing.tipPercentage, + label = existing.label, + btcPayInvoiceId = btcPayInvoiceId ?: existing.btcPayInvoiceId, ) history[index] = updated @@ -631,6 +672,85 @@ 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() + } + + fun markPaymentFailed(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_FAILED, + 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 000000000..61417be01 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/feature/settings/BtcPaySettingsActivity.kt @@ -0,0 +1,190 @@ +package com.electricdreams.numo.feature.settings + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +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 + + private var connectionTestPassed = false + + 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 hasAllFields(): Boolean { + return serverUrlInput.text.toString().isNotBlank() && + apiKeyInput.text.toString().isNotBlank() && + storeIdInput.text.toString().isNotBlank() + } + + private fun updateToggleEnabled() { + val canEnable = hasAllFields() + enableSwitch.isEnabled = canEnable + if (!canEnable && enableSwitch.isChecked) { + enableSwitch.isChecked = false + PreferenceStore.app(this).putBoolean(KEY_ENABLED, false) + } + } + + private fun setupListeners() { + val enableToggleRow = findViewById(R.id.enable_toggle_row) + enableToggleRow.setOnClickListener { + if (hasAllFields()) enableSwitch.toggle() + } + + enableSwitch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked && !connectionTestPassed) { + enableSwitch.isChecked = false + testConnection(onSuccess = { enableSwitch.isChecked = true }) + } else { + PreferenceStore.app(this).putBoolean(KEY_ENABLED, isChecked) + } + } + + val fieldWatcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(s: Editable?) { + connectionTestPassed = false + updateToggleEnabled() + } + } + serverUrlInput.addTextChangedListener(fieldWatcher) + apiKeyInput.addTextChangedListener(fieldWatcher) + storeIdInput.addTextChangedListener(fieldWatcher) + + findViewById(R.id.test_connection_row).setOnClickListener { + testConnection() + } + } + + private fun loadSettings() { + val prefs = PreferenceStore.app(this) + serverUrlInput.setText(prefs.getString(KEY_SERVER_URL, "") ?: "") + apiKeyInput.setText(prefs.getString(KEY_API_KEY, "") ?: "") + storeIdInput.setText(prefs.getString(KEY_STORE_ID, "") ?: "") + val alreadyEnabled = prefs.getBoolean(KEY_ENABLED, false) + if (alreadyEnabled && hasAllFields()) connectionTestPassed = true + val canEnable = hasAllFields() && connectionTestPassed + enableSwitch.isEnabled = canEnable + enableSwitch.isChecked = canEnable && alreadyEnabled + } + + 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(onSuccess: (() -> Unit)? = null) { + 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) { + connectionTestPassed = true + updateToggleEnabled() + onSuccess?.invoke() + 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 2ca982b96..8cf090522 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 @@ -15,6 +15,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. @@ -49,10 +50,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() { @@ -90,18 +117,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 c543559eb..0cee792e0 100644 --- a/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt +++ b/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt @@ -17,7 +17,6 @@ import org.cashudevkit.ReceiveOptions import org.cashudevkit.SplitTarget import org.cashudevkit.Token as CdkToken import java.math.BigInteger -import java.util.Optional /** * Helper class for Cashu payment-related operations. @@ -158,6 +157,79 @@ 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 = org.cashudevkit.PaymentRequest.fromString(paymentRequest) + // Re-build CBOR without transports + val map = com.upokecenter.cbor.CBORObject.NewMap() + decoded.paymentId()?.let { map.Add("i", it) } + decoded.amount()?.let { map.Add("a", it.value) } + decoded.unit()?.let { u -> + val unitStr = when (u) { + is CurrencyUnit.Sat -> "sat" + is CurrencyUnit.Msat -> "msat" + is CurrencyUnit.Eur -> "eur" + is CurrencyUnit.Usd -> "usd" + is CurrencyUnit.Custom -> u.unit + else -> "sat" + } + map.Add("u", unitStr) + } + decoded.description()?.let { map.Add("d", it) } + decoded.singleUse()?.let { map.Add("s", it) } + val mints = decoded.mints() + if (mints.isNotEmpty()) { + val mintsArray = com.upokecenter.cbor.CBORObject.NewArray() + mints.forEach { mintsArray.Add(it) } + map.Add("m", mintsArray) + } + // Omit transports + val cborBytes = map.EncodeToBytes() + "creqA" + android.util.Base64.encodeToString(cborBytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) + } 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 = org.cashudevkit.PaymentRequest.fromString(paymentRequest) + for (t in decoded.transports()) { + if (t.transportType == org.cashudevkit.TransportType.HTTP_POST) { + 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 = org.cashudevkit.PaymentRequest.fromString(paymentRequest) + decoded.paymentId() + } 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 457ce7847..789f2854c 100644 --- a/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt +++ b/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt @@ -52,6 +52,8 @@ class PaymentTabManager( fun onTabSelected(tab: PaymentTab) } + enum class Tab { CASHU, LIGHTNING } + private var listener: TabSelectionListener? = null private var currentTab: PaymentTab? = null @@ -140,4 +142,21 @@ class PaymentTabManager( } fun getCurrentTab(): PaymentTab = currentTab ?: PaymentTab.UNIFIED + + /** + * 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) selectTab(PaymentTab.LIGHTNING) else selectTab(PaymentTab.CASHU) + } + + /** + * Check if Lightning tab is currently visible/selected. + */ + fun isLightningTabSelected(): Boolean = currentTab == PaymentTab.LIGHTNING } 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 d2c42962d..1f3d28b1d 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 @@ -159,6 +159,8 @@ class PaymentsHistoryAdapter : RecyclerView.Adapter() { val entry = item.entry val context = itemView.context val isPending = entry.isPending() + val isExpired = entry.isExpired() + val isFailed = entry.isFailed() // Reset translation immediately without animation to prevent recycled views from staying open mainContent.translationX = if (position == openItemPosition) -getDeleteWidth(context) else 0f @@ -210,7 +212,7 @@ class PaymentsHistoryAdapter : RecyclerView.Adapter() { satAmount.toString() } - val displayAmount = if (isPending) { + val displayAmount = if (isPending || isExpired || isFailed) { formattedAmount } else if (entry.amount >= 0) { "+$formattedAmount" @@ -225,6 +227,7 @@ class PaymentsHistoryAdapter : RecyclerView.Adapter() { // ── Title: direction-based labels ── titleText.text = when { isPending -> context.getString(R.string.history_row_title_pending_payment) + isExpired || isFailed -> context.getString(R.string.history_row_title_pending_payment) entry.amount >= 0 -> context.getString(R.string.history_row_title_payment_received) else -> context.getString(R.string.history_row_title_withdrawal) } @@ -238,22 +241,42 @@ class PaymentsHistoryAdapter : RecyclerView.Adapter() { icon.setColorFilter(context.getColor(R.color.color_text_primary)) // ── Status badge ── - if (isPending) { - statusBadge.setBackgroundResource(R.drawable.bg_status_badge_orange) - statusBadgeIcon.setImageResource(R.drawable.ic_clock_small) - } else { - statusBadge.setBackgroundResource(R.drawable.bg_status_badge_green) - statusBadgeIcon.setImageResource(R.drawable.ic_check_small) + when { + isPending -> { + statusBadge.setBackgroundResource(R.drawable.bg_status_badge_orange) + statusBadgeIcon.setImageResource(R.drawable.ic_clock_small) + } + isExpired || isFailed -> { + statusBadge.setBackgroundResource(R.drawable.bg_status_badge_red) + statusBadgeIcon.setImageResource(R.drawable.ic_clock_small) + } + else -> { + statusBadge.setBackgroundResource(R.drawable.bg_status_badge_green) + statusBadgeIcon.setImageResource(R.drawable.ic_check_small) + } } statusBadge.visibility = View.VISIBLE - // ── Status text (pending only) ── - if (isPending) { - statusText.visibility = View.VISIBLE - statusText.text = context.getString(R.string.history_row_status_tap_to_resume) - statusText.setTextColor(context.getColor(R.color.color_warning)) - } else { - statusText.visibility = View.GONE + // ── Status text (pending/expired/failed) ── + when { + isPending -> { + statusText.visibility = View.VISIBLE + statusText.text = context.getString(R.string.history_row_status_tap_to_resume) + statusText.setTextColor(context.getColor(R.color.color_warning)) + } + isExpired -> { + statusText.visibility = View.VISIBLE + statusText.text = context.getString(R.string.history_row_status_expired) + statusText.setTextColor(context.getColor(R.color.color_error)) + } + isFailed -> { + statusText.visibility = View.VISIBLE + statusText.text = context.getString(R.string.history_row_status_failed) + statusText.setTextColor(context.getColor(R.color.color_error)) + } + else -> { + statusText.visibility = View.GONE + } } // ── Label subtitle ── diff --git a/app/src/main/res/drawable/bg_status_badge_red.xml b/app/src/main/res/drawable/bg_status_badge_red.xml new file mode 100644 index 000000000..7c2ed776a --- /dev/null +++ b/app/src/main/res/drawable/bg_status_badge_red.xml @@ -0,0 +1,8 @@ + + + + + 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 000000000..e74356164 --- /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 4f110b037..9e5be9972 100644 --- a/app/src/main/res/layout/activity_payment_request.xml +++ b/app/src/main/res/layout/activity_payment_request.xml @@ -184,6 +184,7 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/localhost+1-key.pem b/app/src/main/res/localhost+1-key.pem new file mode 100644 index 000000000..96d042a7a --- /dev/null +++ b/app/src/main/res/localhost+1-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDElSSD2MkEKbOy +8wxVUncMcrAqkS3TBIORGZrLBNc+3JgqQVDa2U7U2WmDQ5OIvwbb1TPFf8NnhDV/ +qVeOGMrcDMwUULQAEyBVxt3YvFbs9XUFJ3IsyuxhtHjXor+dx4l6FUU5v8stV0iF +P5/6hrEbGWmapFXDyoOoW3ZbkNic+Xiq8C4qfXKf9aYoCiosvHSsrJ+n5N8bsTOP +UYmrhmLZWpL1ia/yRDes2rnefBG+50n15I3YC94jBv8qniRs2WEzrSUOS9E8Iruq +FbU2jYZ/6Pz0/IQN67Y6sALpTBJWamdZ8B4esjk9ZiKkXP9N1+2XkrYmKsDFk2Bw +Sgfxk8cLAgMBAAECggEAIMx7g64LV/D3RP+tSp0QNNj70JZZbXA+3VpGy+G6Yggf +MUmlimYgc330z0xalMG+jLYlRan7+c0CuhKQg6paSl2uPSN77NlEF1uvTwaZgD6x +8BK1R4jx6JlaYiwKyXHt25sp6ik4Zo++D1FeyUdozEswpfcOQjULQ29DL6LaqVHh +becJbdv3kNH0dII/8CAGTZz8tilkDErhmp/T9OWCr4njfcsmFV7kHaDvb/SYGzAe +Ne5Z+u71LbZ4/w9tDFuGjjKxJNMyXtInUYSkrWAU5gtf6Fbl1YocMyM39N4Ic5xY +JQIVGvvlYRdxKi1OLt26Kqe4QMzfq0B6mct22+nvbQKBgQDZkKmQMr0DBeEdiuhF +2VcYUXC5ooT7ImwozoHad14MGaxYw8lnzUex/qEQgaPr3634X7AuMwuyqLtSIyKs +zA8mZ7+D5p1cEhpaGZUk6jbAbd6RCPh9TOOvPooOGpKo8q/N4WCVe4YpDc/KO01J +O4xEhIqsM8lV45swbfKBtz42BQKBgQDnT40LcgMmjJ4kzAinpPn1kum23Y9Rj8a5 +aFahSqtK76l5VSWfxpDcnv6UWlOoP19OqLjdbmevGMTRLUJ0yeBkLBx4HPZjCvPb +a60BzL+bWiHJurbeHK3xiJx9XlWlJUsj2hEhA4NVzVqpbODU6bZ0QmKWYk+dAVCb +Xux0/9gFzwKBgEJU3a2uGnxqdXj5Wdm56tjqM5EVYK/kjc9fLq35yL2tsiMaBjTU +nHBDLr4GmICYoMTh/6gGPiHJWdswBSljyZau+O/xBrcEee5QcG1hzzGaDcpwTrp9 +D8nlKlgkd+R0oW8GsNjCYWPw5xJEREr4kcpuEo1v+IlsLGt2igJMaPY1AoGAH4zH +NTdw3JIzg9tcltk2ytsmC64+vSY6OdHUdx2DLa5w1D7b6eYgnicFnGCRppI2Qrla +tcE4XTaoqctdlCZw99jYbT2uEaZNyrDuIR+3Rs5Na4GPLc6Fnzs99Q+n6OWkURiO +W41qHYrsAc37AK98FnFzlwWDzGuAfiC9adv3sBsCgYEAiJWmZ2pN+xiaPbr+3s3B +ECYJv4+9Hr2McJmTkjVd2KzRWRb885d9pyd7d7WQyEAD9+cXhCkwuXIuozikbykz +kp65nxL21adzTAcqkVKPfzyUiJeN7EDXO5jmy1s/hvw6j7U/4o37H2bTSBjBS81y +/VqycrRuEOYRI3PXS7apgM4= +-----END PRIVATE KEY----- diff --git a/app/src/main/res/localhost+1.pem b/app/src/main/res/localhost+1.pem new file mode 100644 index 000000000..c2fb52053 --- /dev/null +++ b/app/src/main/res/localhost+1.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEZDCCAsygAwIBAgIRAPpPJO9QOkmxCfA0/39ZKAwwDQYJKoZIhvcNAQELBQAw +gZExHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEzMDEGA1UECwwqcGF0 +cnlrZGFybW9mYWxza2lATWFjQm9vay1Qcm8tUGF0cnlrLmxvY2FsMTowOAYDVQQD +DDFta2NlcnQgcGF0cnlrZGFybW9mYWxza2lATWFjQm9vay1Qcm8tUGF0cnlrLmxv +Y2FsMB4XDTI2MDEyOTEyMjEwNFoXDTI4MDQyOTExMjEwNFowYzEnMCUGA1UEChMe +bWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTgwNgYDVQQLDC9wYXRyeWtk +YXJtb2ZhbHNraUBtYWMuaG9tZSAoUGF0cnlrIERhcm1vZmFsc2tpKTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMSVJIPYyQQps7LzDFVSdwxysCqRLdME +g5EZmssE1z7cmCpBUNrZTtTZaYNDk4i/BtvVM8V/w2eENX+pV44YytwMzBRQtAAT +IFXG3di8Vuz1dQUncizK7GG0eNeiv53HiXoVRTm/yy1XSIU/n/qGsRsZaZqkVcPK +g6hbdluQ2Jz5eKrwLip9cp/1pigKKiy8dKysn6fk3xuxM49RiauGYtlakvWJr/JE +N6zaud58Eb7nSfXkjdgL3iMG/yqeJGzZYTOtJQ5L0Twiu6oVtTaNhn/o/PT8hA3r +tjqwAulMElZqZ1nwHh6yOT1mIqRc/03X7ZeStiYqwMWTYHBKB/GTxwsCAwEAAaNk +MGIwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQY +MBaAFIUix7tiwvf5FuXANL+8oW5NiFcZMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcE +CgACAjANBgkqhkiG9w0BAQsFAAOCAYEAZLPqCc5PK6PWAeZJzOgCbkG9BY894GOC +lqYUlaLWopNon+VcFNgExwr6kXj783h+UkKXldo/KzuE1UqhCLO/JSChm37LJSvq +MGvpjNj+8WweqKzF9JSW7pE5NDCbc727gPk+aknAykuqa9N1Bj/r7GaOEJtUbqEQ +Gxe5dt2gDmpijjNNLFYZz72NaZd36pagphEJDRxr6dS2XgvIqlVvnfOeAYB09FiW +QbxYdKCLW556Z5N+JJE2lgIy2iZdkhGT7UUPycX4Fm215WvGG9tW+cqLST21b6Ec +vRrHRg8XWYvBp3SwQjqlXwZUCsZN09bCla37ZKSBaBPBcomqWq5jFtLz8VuUZHC7 +OHjSidRqfrem+iMXbnhwt69WBRmpy492sLqNVkLSLvsqMirOzR/PCuxySiO+a9Kv +1y2Axj4cdF7Qode3nB/hGpAl8dcLmCLZ3TeiUBn6cHT2XbtJ/ant6rFBUxnEiQRE +0aFMRQv2m3rpnUOxfaggfLoED55fuzlN +-----END CERTIFICATE----- diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7c5446268..6b53c4a64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -896,4 +896,25 @@ Unified Payment of %1$d sats Numo POS payment of %1$d sats + + + 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 ba4ffd610..ba1d3df58 100644 --- a/app/src/main/res/values/strings_history.xml +++ b/app/src/main/res/values/strings_history.xml @@ -12,6 +12,8 @@ Payment Received Withdrawal Tap to resume + Expired + Failed Open payment with... diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..b6502b190 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/test/java/com/electricdreams/numo/core/cashu/CashuWalletManagerTest.kt b/app/src/test/java/com/electricdreams/numo/core/cashu/CashuWalletManagerTest.kt index dd59ef9e7..df8a1c401 100644 --- a/app/src/test/java/com/electricdreams/numo/core/cashu/CashuWalletManagerTest.kt +++ b/app/src/test/java/com/electricdreams/numo/core/cashu/CashuWalletManagerTest.kt @@ -2,8 +2,6 @@ package com.electricdreams.numo.core.cashu import android.content.Context import androidx.test.core.app.ApplicationProvider -import com.electricdreams.numo.core.cashu.CashuWalletManager -import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -11,7 +9,6 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import java.lang.reflect.Field @RunWith(RobolectricTestRunner::class) class CashuWalletManagerTest { @@ -90,12 +87,162 @@ class CashuWalletManagerTest { @Test fun testMnemonicStorage() { setPrivateField("appContext", context) - + val mnemonic = "test mnemonic code" // Correct prefs name from PreferenceStore.WALLET_PREFS_NAME ("cashu_wallet_prefs") val prefs = context.getSharedPreferences("cashu_wallet_prefs", Context.MODE_PRIVATE) prefs.edit().putString("wallet_mnemonic", mnemonic).apply() - + assertEquals(mnemonic, CashuWalletManager.getMnemonic()) } + + @Test + fun testGetMnemonic_whenNotInitialized_returnsNull() { + // appContext is not initialized → getMnemonic must return null, not throw + assertNull(CashuWalletManager.getMnemonic()) + } + + @Test + fun testGetWallet_whenNotInitialized_returnsNull() { + assertNull(CashuWalletManager.getWallet()) + } + + @Test + fun testMintInfoFromJson_emptyJson_returnsNullName() { + val cachedInfo = CashuWalletManager.mintInfoFromJson("{}") + assertNotNull("empty JSON object should still return a CachedMintInfo", cachedInfo) + assertNull("name should be null for empty JSON", cachedInfo?.name) + assertNull("versionInfo should be null for empty JSON", cachedInfo?.versionInfo) + assertEquals("contact list should be empty for empty JSON", 0, cachedInfo?.contact?.size) + } + + @Test + fun testMintInfoFromJson_invalidJson_returnsNull() { + assertNull("invalid JSON should return null", CashuWalletManager.mintInfoFromJson("not-json")) + assertNull("bare string should return null", CashuWalletManager.mintInfoFromJson("null")) + assertNull("empty string should return null", CashuWalletManager.mintInfoFromJson("")) + } + + @Test + fun testMintInfoFromJson_nullFields_handledGracefully() { + val json = """{"name": null, "description": null, "motd": null}""" + val cachedInfo = CashuWalletManager.mintInfoFromJson(json) + assertNotNull(cachedInfo) + assertNull("explicit JSON null name should be null in result", cachedInfo?.name) + assertNull("explicit JSON null description should be null", cachedInfo?.description) + assertNull("explicit JSON null motd should be null", cachedInfo?.motd) + } + + @Test + fun testMintInfoFromJson_multipleContacts() { + val json = """ + { + "name": "Multi-Contact Mint", + "contact": [ + {"method": "email", "info": "a@example.com"}, + {"method": "twitter", "info": "@mintoperator"}, + {"method": "nostr", "info": "npub1xxx"} + ] + } + """.trimIndent() + val cachedInfo = CashuWalletManager.mintInfoFromJson(json) + assertNotNull(cachedInfo) + assertEquals("Should have 3 contacts", 3, cachedInfo?.contact?.size) + assertEquals("email", cachedInfo?.contact?.get(0)?.method) + assertEquals("a@example.com", cachedInfo?.contact?.get(0)?.info) + assertEquals("twitter", cachedInfo?.contact?.get(1)?.method) + assertEquals("nostr", cachedInfo?.contact?.get(2)?.method) + } + + @Test + fun testMintInfoFromJson_contactMissingField_skipped() { + // Contact without "info" field should be skipped (both fields required) + val json = """ + { + "name": "Test", + "contact": [ + {"method": "email"}, + {"method": "twitter", "info": "@ok"} + ] + } + """.trimIndent() + val cachedInfo = CashuWalletManager.mintInfoFromJson(json) + assertNotNull(cachedInfo) + // Only the complete contact should be present + assertEquals("Incomplete contact should be skipped", 1, cachedInfo?.contact?.size) + assertEquals("twitter", cachedInfo?.contact?.get(0)?.method) + } + + @Test + fun testMintInfoFromJson_versionAsObject_parsed() { + val json = """ + { + "name": "CDK Mint", + "version": {"name": "cdk-mintd", "version": "0.16.0"} + } + """.trimIndent() + val cachedInfo = CashuWalletManager.mintInfoFromJson(json) + assertNotNull(cachedInfo) + assertNotNull(cachedInfo?.versionInfo) + assertEquals("cdk-mintd", cachedInfo?.versionInfo?.name) + assertEquals("0.16.0", cachedInfo?.versionInfo?.version) + } + + @Test + fun testMintInfoFromJson_allOptionalFieldsPresent() { + val json = """ + { + "name": "Full Mint", + "description": "Short desc", + "descriptionLong": "A longer description with more detail", + "motd": "Message of the day", + "iconUrl": "https://example.com/icon.svg", + "pubkey": "02abc", + "version": {"name": "nutshell", "version": "0.15.3"}, + "contact": [{"method": "email", "info": "ops@example.com"}] + } + """.trimIndent() + val cachedInfo = CashuWalletManager.mintInfoFromJson(json) + assertNotNull(cachedInfo) + assertEquals("Full Mint", cachedInfo?.name) + assertEquals("Short desc", cachedInfo?.description) + assertEquals("A longer description with more detail", cachedInfo?.descriptionLong) + assertEquals("Message of the day", cachedInfo?.motd) + assertEquals("https://example.com/icon.svg", cachedInfo?.iconUrl) + assertNotNull(cachedInfo?.versionInfo) + assertEquals(1, cachedInfo?.contact?.size) + } + + @Test + fun testCachedMintInfo_dataClass_equalsAndCopy() { + val info1 = CashuWalletManager.CachedMintInfo( + name = "Test", + description = null, + descriptionLong = null, + versionInfo = null, + motd = null, + iconUrl = null, + contact = emptyList() + ) + val info2 = info1.copy(name = "Test2") + assertEquals("Test", info1.name) + assertEquals("Test2", info2.name) + // Other fields should be equal + assertEquals(info1.contact, info2.contact) + } + + @Test + fun testCachedVersionInfo_dataClass() { + val v = CashuWalletManager.CachedVersionInfo(name = "mintd", version = "1.0.0") + assertEquals("mintd", v.name) + assertEquals("1.0.0", v.version) + assertEquals(v, v.copy()) + } + + @Test + fun testCachedContactInfo_dataClass() { + val c = CashuWalletManager.CachedContactInfo(method = "email", info = "a@b.com") + assertEquals("email", c.method) + assertEquals("a@b.com", c.info) + } } 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 000000000..5614e4588 --- /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 000000000..24c3b4a29 --- /dev/null +++ b/app/src/test/java/com/electricdreams/numo/core/payment/impl/BtcPayPaymentServiceIntegrationTest.kt @@ -0,0 +1,810 @@ +package com.electricdreams.numo.core.payment.impl + +import android.util.Base64 +import com.electricdreams.numo.core.payment.BTCPayConfig +import com.electricdreams.numo.core.payment.PaymentState +import com.electricdreams.numo.core.wallet.WalletResult +import com.google.gson.JsonParser +import com.upokecenter.cbor.CBORObject +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assert.* +import org.junit.Assume.assumeFalse +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() + private var cashuEnabled = false + private var cdkMintUrl: String = "http://localhost:3338" + private var customerLndUrl: String = "http://localhost:35532" + + @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"), + ) + cashuEnabled = props.getProperty("CASHU_ENABLED", "false").toBoolean() + cdkMintUrl = props.getProperty("CDK_MINT_URL", "http://localhost:3338") + customerLndUrl = props.getProperty("CUSTOMER_LND_URL", "http://localhost:35532") + } else { + config = BTCPayConfig( + serverUrl = System.getenv("BTCPAY_SERVER_URL") ?: "http://localhost:49392", + apiKey = System.getenv("BTCPAY_API_KEY") ?: "", + storeId = System.getenv("BTCPAY_STORE_ID") ?: "", + ) + cashuEnabled = System.getenv("CASHU_ENABLED")?.toBoolean() ?: false + cdkMintUrl = System.getenv("CDK_MINT_URL") ?: "http://localhost:3338" + customerLndUrl = System.getenv("CUSTOMER_LND_URL") ?: "http://localhost:35532" + } + service = BTCPayPaymentService(config) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun skipIfNotConfigured(): Boolean { + if (config.apiKey.isEmpty() || config.storeId.isEmpty()) { + println("SKIP: BTCPay not configured") + return true + } + return false + } + + private fun skipIfCashuNotEnabled() { + assumeFalse("BTCPay not configured — skipping", config.apiKey.isEmpty() || config.storeId.isEmpty()) + assumeFalse("Cashu not enabled — skipping", !cashuEnabled) + } + + private fun baseUrl(): String = config.serverUrl.trimEnd('/') + + private fun cashuGet(path: String): Pair { + val url = "${baseUrl()}/api/v1/stores/${config.storeId}/cashu$path" + val request = Request.Builder() + .url(url).get() + .addHeader("Authorization", "token ${config.apiKey}") + .build() + return executeHttpRequest(request) + } + + private fun cashuPut(path: String, body: String): Pair { + val url = "${baseUrl()}/api/v1/stores/${config.storeId}/cashu$path" + val request = Request.Builder() + .url(url).put(body.toRequestBody(jsonMediaType)) + .addHeader("Authorization", "token ${config.apiKey}") + .build() + return executeHttpRequest(request) + } + + private fun cashuPost(path: String, body: String = ""): Pair { + val url = "${baseUrl()}/api/v1/stores/${config.storeId}/cashu$path" + val request = Request.Builder() + .url(url).post(body.toRequestBody(jsonMediaType)) + .addHeader("Authorization", "token ${config.apiKey}") + .build() + return executeHttpRequest(request) + } + + private fun publicPost(path: String, body: String): Pair { + val request = Request.Builder() + .url("${baseUrl()}$path") + .post(body.toRequestBody(jsonMediaType)) + .build() + return executeHttpRequest(request) + } + + private fun publicPostForm(url: String, queryParams: String): Pair { + val request = Request.Builder() + .url("$url?$queryParams") + .post("".toRequestBody(jsonMediaType)) + .build() + return executeHttpRequest(request) + } + + private fun executeHttpRequest(request: Request): Pair { + httpClient.newCall(request).execute().use { response -> + return Pair(response.code, response.body?.string() ?: "") + } + } + + // --- CBOR-based cashuPR parsing (avoids CDK native lib dependency) --- + + private fun decodeCashuPR(creq: String): CBORObject? { + if (!creq.startsWith("creqA")) return null + val bytes = Base64.decode(creq.removePrefix("creqA"), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + return CBORObject.DecodeFromBytes(bytes) + } + + private fun getIdFromPR(creq: String): String? = decodeCashuPR(creq)?.get("i")?.AsString() + + private fun getPostUrlFromPR(creq: String): String? { + val transports = decodeCashuPR(creq)?.get("t") ?: return null + for (i in 0 until transports.size()) { + val t = transports[i] + if (t["t"]?.AsString().equals("post", ignoreCase = true)) return t["a"]?.AsString() + } + return null + } + + private fun stripTransportsFromPR(creq: String): String? { + val cbor = decodeCashuPR(creq) ?: return null + val map = CBORObject.NewMap() + for (key in cbor.keys) { + val k = key.AsString() + if (k != "t") map.Add(k, cbor[key]) + } + return "creqA" + Base64.encodeToString(map.EncodeToBytes(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } + + private fun createInvoiceAndGetCashuPR(amountSats: Long = 1000L): Pair { + val invoiceId = createInvoiceId(amountSats) + val url = "${baseUrl()}/api/v1/stores/${config.storeId}/invoices/$invoiceId/payment-methods" + val request = Request.Builder().url(url).get() + .addHeader("Authorization", "token ${config.apiKey}").build() + val (code, body) = executeHttpRequest(request) + assertEquals("Payment methods request should succeed", 200, code) + + val array = JsonParser.parseString(body).asJsonArray + var cashuPR: String? = null + for (element in array) { + val obj = element.asJsonObject + val pm = obj.get("paymentMethodId")?.takeIf { !it.isJsonNull }?.asString ?: "" + if (pm.contains("Cashu", ignoreCase = true)) { + cashuPR = obj.get("destination")?.takeIf { !it.isJsonNull }?.asString + } + } + return Pair(invoiceId, cashuPR) + } + + /** + * 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) + } + + // ========================================================================= + // Cashu Greenfield API — smoke tests + // ========================================================================= + + @Test + fun testGetCashuConfig_returnsEnabled() { + skipIfCashuNotEnabled() + val (code, body) = cashuGet("") + assertEquals("GET /cashu should return 200", 200, code) + val json = JsonParser.parseString(body).asJsonObject + assertTrue("Cashu should be enabled", json.get("enabled").asBoolean) + assertNotNull("paymentModel should be present", json.get("paymentModel")) + println("Cashu config: $body") + } + + @Test + fun testUpdateCashuConfig_disableAndReEnable() { + skipIfCashuNotEnabled() + val (disableCode, disableBody) = cashuPut("", """{"enabled": false}""") + assertEquals(200, disableCode) + assertFalse(JsonParser.parseString(disableBody).asJsonObject.get("enabled").asBoolean) + val (enableCode, enableBody) = cashuPut("", """{"enabled": true}""") + assertEquals(200, enableCode) + assertTrue(JsonParser.parseString(enableBody).asJsonObject.get("enabled").asBoolean) + } + + @Test + fun testGetWalletBalances_emptyAfterCreation() { + skipIfCashuNotEnabled() + val (code, body) = cashuGet("/wallet/balances") + assertEquals("GET /wallet/balances should return 200", 200, code) + val balances = JsonParser.parseString(body).asJsonObject.getAsJsonArray("balances") + assertEquals("Fresh wallet should have no balances", 0, balances.size()) + } + + @Test + fun testCreateWallet_duplicateFails() { + skipIfCashuNotEnabled() + val (code, body) = cashuPost("/wallet") + assertNotEquals("Duplicate wallet creation should not return 200", 200, code) + println("Duplicate wallet response (HTTP $code): $body") + } + + // ========================================================================= + // Cashu — Invoice + Payment Request + // ========================================================================= + + @Test + fun testCreateInvoice_hasCashuPaymentRequest() { + skipIfCashuNotEnabled() + val (invoiceId, cashuPR) = createInvoiceAndGetCashuPR(1000L) + assertNotNull("Invoice should have a Cashu payment request", cashuPR) + assertTrue("Cashu PR should start with 'creqA'", cashuPR!!.startsWith("creqA")) + println("Invoice $invoiceId cashuPR: ${cashuPR.take(50)}...") + } + + @Test + fun testCashuPR_canBeParsedForId() { + skipIfCashuNotEnabled() + val (_, cashuPR) = createInvoiceAndGetCashuPR(500L) + assertNotNull("Cashu PR should not be null", cashuPR) + val id = getIdFromPR(cashuPR!!) + assertNotNull("Payment request should have an ID", id) + assertTrue("Payment request ID should not be blank", id!!.isNotBlank()) + println("Cashu PR ID: $id") + } + + @Test + fun testCashuPR_hasPostTransport() { + skipIfCashuNotEnabled() + val (_, cashuPR) = createInvoiceAndGetCashuPR(500L) + assertNotNull("Cashu PR should not be null", cashuPR) + val postUrl = getPostUrlFromPR(cashuPR!!) + assertNotNull("Payment request should have a POST transport URL", postUrl) + assertTrue("POST URL should point to pay-invoice-pr", postUrl!!.contains("cashu/pay-invoice-pr")) + println("Cashu PR POST URL: $postUrl") + } + + @Test + fun testCashuPR_strippedTransports() { + skipIfCashuNotEnabled() + val (_, cashuPR) = createInvoiceAndGetCashuPR(500L) + assertNotNull("Cashu PR should not be null", cashuPR) + val stripped = stripTransportsFromPR(cashuPR!!) + assertNotNull("stripTransports() should return a valid string", stripped) + assertTrue("Stripped PR should still start with 'creqA'", stripped!!.startsWith("creqA")) + assertNull("Stripped PR should not have a POST transport", getPostUrlFromPR(stripped)) + assertEquals("Stripped PR should preserve the payment ID", getIdFromPR(cashuPR), getIdFromPR(stripped)) + } + + @Test + fun testCashuPR_differentAmountsProduceDifferentRequests() { + skipIfCashuNotEnabled() + val (_, pr1) = createInvoiceAndGetCashuPR(100L) + val (_, pr2) = createInvoiceAndGetCashuPR(5000L) + assertNotNull(pr1) + assertNotNull(pr2) + assertNotEquals("Different amounts should produce different payment requests", pr1, pr2) + assertNotEquals("Different invoices should have different PR IDs", getIdFromPR(pr1!!), getIdFromPR(pr2!!)) + } + + // ========================================================================= + // Cashu — Token & Transaction API + // ========================================================================= + + @Test + fun testGetExportedTokens_emptyInitially() { + skipIfCashuNotEnabled() + val (code, body) = cashuGet("/tokens") + assertEquals("GET /tokens should return 200", 200, code) + assertEquals("Fresh store should have no exported tokens", 0, JsonParser.parseString(body).asJsonArray.size()) + } + + @Test + fun testGetFailedTransactions_emptyInitially() { + skipIfCashuNotEnabled() + val (code, body) = cashuGet("/failed-transactions") + assertEquals("GET /failed-transactions should return 200", 200, code) + assertEquals("Fresh store should have no failed transactions", 0, JsonParser.parseString(body).asJsonArray.size()) + } + + // ========================================================================= + // Cashu — Error handling + // ========================================================================= + + @Test + fun testCashuPayInvoice_invalidToken_returnsBadRequest() { + skipIfCashuNotEnabled() + val invoiceId = createInvoiceId(1000L) + val (code, body) = publicPostForm("${baseUrl()}/cashu/pay-invoice", "token=cashuAinvalidgarbage&invoiceId=$invoiceId") + assertEquals("Invalid token should return 400", 400, code) + println("pay-invoice error (HTTP $code): $body") + } + + @Test + fun testCashuPayInvoicePr_invalidPayload_returnsBadRequest() { + skipIfCashuNotEnabled() + val (code, body) = publicPost("/cashu/pay-invoice-pr", """{"id": "fake-id", "mint": "http://fake-mint", "unit": "sat", "proofs": []}""") + assertTrue("Invalid NUT-19 payload should return 4xx", code in 400..499) + println("pay-invoice-pr error (HTTP $code): $body") + } + + @Test + fun testCashuPayInvoice_emptyToken_returnsBadRequest() { + skipIfCashuNotEnabled() + val invoiceId = createInvoiceId(1000L) + val (code, _) = publicPostForm("${baseUrl()}/cashu/pay-invoice", "token=&invoiceId=$invoiceId") + assertEquals("Empty token should return 400", 400, code) + } + + @Test + fun testCashuRedeemToken_viaService_invalidToken_returnsFailure() = runBlocking { + skipIfCashuNotEnabled() + val invoiceId = createInvoiceId(1000L) + val result = service.redeemToken("cashuBinvalidtoken", invoiceId) + assertTrue("redeemToken with invalid token should fail", result is WalletResult.Failure) + } + + // ========================================================================= + // Cashu — BTCPayPaymentService integration + // ========================================================================= + + @Test + fun testCashuCreatePayment_returnsCashuPR() = runBlocking { + skipIfCashuNotEnabled() + val result = service.createPayment(1000L, "Cashu service test") + assertTrue("createPayment should succeed", result is WalletResult.Success) + val data = result.getOrThrow() + assertNotNull("PaymentData should have cashuPR", data.cashuPR) + assertTrue("cashuPR should start with creqA", data.cashuPR!!.startsWith("creqA")) + println("Service cashuPR: ${data.cashuPR.take(50)}...") + } + + @Test + fun testCashuFetchExistingPaymentData_returnsCashuPR() = runBlocking { + skipIfCashuNotEnabled() + val invoiceId = createInvoiceId(500L) + val result = service.fetchExistingPaymentData(invoiceId) + assertTrue("fetchExistingPaymentData should succeed", result is WalletResult.Success) + val data = result.getOrThrow() + assertEquals("Should return same invoice ID", invoiceId, data.paymentId) + assertNotNull("Should include cashuPR", data.cashuPR) + } + + // ========================================================================= + // Lightning e2e + // ========================================================================= + + /** + * Full e2e: create BTCPay Lightning invoice → pay bolt11 via customer_lnd + * → poll until invoice is PAID. + * + * Requires the channel customer_lnd → lnd_bitcoin set up by channel-setup.sh. + */ + @Test + fun testE2E_lightningInvoicePaidViaCustomerLnd() = runBlocking { + if (skipIfNotConfigured()) return@runBlocking + assumeFalse( + "customer_lnd not reachable at $customerLndUrl — skipping e2e test", + !isCustomerLndReachable() + ) + + // 1. Create BTCPay invoice and fetch bolt11 + val data = service.createPayment(1000L, "LN e2e test").getOrThrow() + val bolt11 = data.bolt11 + assertNotNull("Invoice should have a bolt11", bolt11) + println("BTCPay invoice: ${data.paymentId}") + println("Paying: ${bolt11!!.take(60)}...") + + // 2. Pay via customer_lnd + val payReq = Request.Builder() + .url("$customerLndUrl/v1/channels/transactions") + .post("""{"payment_request": "$bolt11"}""".toRequestBody(jsonMediaType)) + .build() + val (payCode, payResp) = executeHttpRequest(payReq) + println("customer_lnd pay (HTTP $payCode): ${payResp.take(200)}") + assertTrue("customer_lnd should accept payment (HTTP $payCode)", payCode in 200..299) + + // 3. Poll BTCPay until invoice is PAID (up to 30 s) + val isPaid = pollBtcPayInvoicePaid(data.paymentId, timeoutMs = 30_000) + assertTrue("BTCPay invoice should become PAID after LN payment", isPaid) + println("BTCPay invoice PAID — Lightning e2e passed!") + } + + private suspend fun pollBtcPayInvoicePaid(invoiceId: String, timeoutMs: Long): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val status = service.checkPaymentStatus(invoiceId).getOrNull() + println(" BTCPay invoice status: $status") + if (status == PaymentState.PAID) return true + kotlinx.coroutines.delay(2_000) + } + return false + } + + // ========================================================================= + // CDK Mint + BTCPay — combined e2e + // ========================================================================= + + private fun isCdkMintReachable(): Boolean = try { + val req = Request.Builder().url("$cdkMintUrl/v1/info").get().build() + httpClient.newCall(req).execute().use { it.code == 200 } + } catch (_: Exception) { false } + + private fun skipIfCdkMintNotReachable() { + assumeFalse("CDK mint not reachable at $cdkMintUrl — skipping", !isCdkMintReachable()) + } + + /** + * Verifies that BTCPay's Cashu config lists the local CDK mint as a trusted mint. + * This is set up by provision.sh using the Docker-internal URL (http://cdk-mint:3338). + * BTCPay resolves it internally; the display URL may differ from cdkMintUrl. + */ + @Test + fun testBtcPayTrustsCdkMint() { + skipIfCashuNotEnabled() + skipIfCdkMintNotReachable() + val (code, body) = cashuGet("") + assertEquals("GET /cashu should return 200", 200, code) + val trustedMints = JsonParser.parseString(body).asJsonObject + .getAsJsonArray("trustedMintsUrls") + val hasCdkMint = trustedMints.any { url -> + // provision.sh sets "http://cdk-mint:3338" (Docker hostname) + url.asString.contains("cdk-mint") || url.asString.contains("3338") + } + assertTrue( + "BTCPay should trust the CDK mint (trustedMintsUrls=$trustedMints)", + hasCdkMint + ) + println("BTCPay trusted mints: $trustedMints") + } + + /** + * Creates a BTCPay invoice, creates a CDK mint quote for the same amount, + * pays the CDK mint invoice via customer_lnd, and verifies the CDK quote + * becomes PAID — confirming the Lightning topology works end-to-end. + * + * Note: completing the payment to BTCPay (redeeming the minted token via + * NUT-18/19) requires Cashu token blinding which is not available in JVM + * tests without CDK native libs. This test validates the infrastructure layer. + */ + @Test + fun testE2E_cdkMintQuotePaidViaCustomerLnd() = runBlocking { + skipIfCashuNotEnabled() + skipIfCdkMintNotReachable() + assumeFalse( + "customer_lnd not reachable at $customerLndUrl — skipping e2e test", + !isCustomerLndReachable() + ) + + val amountSats = 100L + + // Create a BTCPay invoice (proves BTCPay + CDK mint are wired together) + val btcpayInvoiceId = createInvoiceId(amountSats, "CDK e2e test") + assertFalse("BTCPay invoice ID should not be blank", btcpayInvoiceId.isBlank()) + println("BTCPay invoice: $btcpayInvoiceId") + + // Create a CDK mint quote (get bolt11 from CDK mint) + val mintQuoteReq = Request.Builder() + .url("$cdkMintUrl/v1/mint/quote/bolt11") + .post("""{"amount": $amountSats, "unit": "sat"}""".toRequestBody(jsonMediaType)) + .build() + val (quoteCode, quoteBody) = httpClient.newCall(mintQuoteReq).execute().use { + it.code to (it.body?.string() ?: "") + } + assertEquals("CDK mint quote should succeed", 200, quoteCode) + val quoteJson = JsonParser.parseString(quoteBody).asJsonObject + val quoteId = quoteJson.get("quote").asString + val bolt11 = quoteJson.get("request").asString + println("CDK mint quote: $quoteId") + println("Paying: ${bolt11.take(60)}...") + + // Pay via customer_lnd + val payReq = Request.Builder() + .url("$customerLndUrl/v1/channels/transactions") + .post("""{"payment_request": "$bolt11"}""".toRequestBody(jsonMediaType)) + .build() + val (payCode, payResp) = httpClient.newCall(payReq).execute().use { + it.code to (it.body?.string() ?: "") + } + println("LND pay (HTTP $payCode): ${payResp.take(200)}") + assertTrue("customer_lnd should accept payment (HTTP $payCode)", payCode in 200..299) + + // Poll CDK mint until quote is PAID (up to 30 s) + val isPaid = pollCdkQuotePaid(quoteId, timeoutMs = 30_000) + assertTrue("CDK mint quote should be PAID after LN payment", isPaid) + println("CDK mint quote PAID — e2e infrastructure test passed!") + } + + private fun isCustomerLndReachable(): Boolean = try { + val req = Request.Builder().url("$customerLndUrl/v1/getinfo").get().build() + httpClient.newCall(req).execute().use { it.code == 200 } + } catch (_: Exception) { false } + + private suspend fun pollCdkQuotePaid(quoteId: String, timeoutMs: Long): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + try { + val req = Request.Builder() + .url("$cdkMintUrl/v1/mint/quote/bolt11/$quoteId") + .get().build() + httpClient.newCall(req).execute().use { resp -> + if (resp.code == 200) { + val state = JsonParser.parseString(resp.body?.string() ?: "{}") + .asJsonObject.get("state")?.asString + println(" CDK quote state: $state") + if (state == "PAID") return true + } + } + } catch (_: Exception) { /* retry */ } + kotlinx.coroutines.delay(2_000) + } + return false + } +} 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 000000000..9a34479e7 --- /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 d50f5dd59..fbe31accf 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 000000000..6e1752863 --- /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/channel-setup.sh b/integration-tests/btcpay/channel-setup.sh new file mode 100755 index 000000000..b27b36340 --- /dev/null +++ b/integration-tests/btcpay/channel-setup.sh @@ -0,0 +1,134 @@ +#!/bin/sh +set -e + +CUSTOMER_LND_URL="${CUSTOMER_LND_URL:-http://customer_lnd:8080}" +MINT_LND_URL="${MINT_LND_URL:-http://mint_lnd:8080}" +BTCPAY_LND_URL="${BTCPAY_LND_URL:-http://lnd_bitcoin:8080}" +MINT_MACAROON_PATH="${MINT_MACAROON_PATH:-/mint_lnd_data/data/chain/bitcoin/regtest/admin.macaroon}" +BTC_RPC_URL="${BTC_RPC_URL:-http://bitcoind:18443}" +BTC_RPC_USER="${BTC_RPC_USER:-btcpay}" +BTC_RPC_PASS="${BTC_RPC_PASS:-btcpay}" + +echo "=== Channel Setup ===" +echo " customer_lnd : $CUSTOMER_LND_URL" +echo " mint_lnd : $MINT_LND_URL" +echo " lnd_bitcoin : $BTCPAY_LND_URL" + +# ── helpers ─────────────────────────────────────────────────────────────────── + +btc_rpc() { + curl -sf -u "$BTC_RPC_USER:$BTC_RPC_PASS" -X POST "$BTC_RPC_URL" \ + -H "Content-Type: application/json" -d "$1" +} + +lnd_get() { + local url="$1" path="$2" mac="$3" + if [ -n "$mac" ]; then + curl -sf -H "Grpc-Metadata-macaroon: $mac" "$url$path" + else + curl -sf "$url$path" + fi +} + +lnd_post() { + local url="$1" path="$2" body="$3" mac="$4" + if [ -n "$mac" ]; then + curl -sf -X POST -H "Content-Type: application/json" \ + -H "Grpc-Metadata-macaroon: $mac" -d "$body" "$url$path" + else + curl -sf -X POST -H "Content-Type: application/json" -d "$body" "$url$path" + fi +} + +wait_for_lnd() { + local url="$1" name="$2" mac="$3" + echo "Waiting for $name..." + for i in $(seq 1 60); do + code=$(lnd_get "$url" "/v1/getinfo" "$mac" 2>/dev/null | jq -e '.identity_pubkey' >/dev/null 2>&1 && echo 200 || echo 0) + [ "$code" = "200" ] && { echo " $name ready."; return 0; } + echo " $name not ready ($i/60)..." + sleep 3 + done + echo "ERROR: $name did not become ready"; exit 1 +} + +# ── wait for macaroon ───────────────────────────────────────────────────────── + +echo "Waiting for mint_lnd macaroon at $MINT_MACAROON_PATH..." +for i in $(seq 1 60); do + [ -f "$MINT_MACAROON_PATH" ] && break + echo " waiting ($i/60)..." + sleep 3 +done +[ -f "$MINT_MACAROON_PATH" ] || { echo "ERROR: macaroon not found"; exit 1; } + +MACAROON=$(xxd -p -c 1000 "$MINT_MACAROON_PATH" | tr -d '\n') + +wait_for_lnd "$CUSTOMER_LND_URL" "customer_lnd" "" +wait_for_lnd "$MINT_LND_URL" "mint_lnd" "$MACAROON" +wait_for_lnd "$BTCPAY_LND_URL" "lnd_bitcoin" "" + +# ── fund customer_lnd ───────────────────────────────────────────────────────── + +echo "Getting customer_lnd funding address..." +CUSTOMER_ADDR=$(lnd_get "$CUSTOMER_LND_URL" "/v1/newaddress?type=0" "" | jq -r '.address') +echo " address: $CUSTOMER_ADDR" + +echo "Mining 110 blocks to customer_lnd..." +btc_rpc "{\"jsonrpc\":\"1.0\",\"id\":\"1\",\"method\":\"generatetoaddress\",\"params\":[110,\"$CUSTOMER_ADDR\"]}" > /dev/null + +echo "Waiting for customer_lnd balance..." +for i in $(seq 1 30); do + BAL=$(lnd_get "$CUSTOMER_LND_URL" "/v1/balance/blockchain" "" | jq -r '.confirmed_balance // "0"') + echo " balance: $BAL sats" + [ "$BAL" -gt 0 ] 2>/dev/null && break + sleep 3 +done + +# ── channel: customer_lnd → mint_lnd ───────────────────────────────────────── + +echo "Getting mint_lnd pubkey..." +MINT_PUBKEY=$(lnd_get "$MINT_LND_URL" "/v1/getinfo" "$MACAROON" | jq -r '.identity_pubkey') +echo " mint_lnd pubkey: $MINT_PUBKEY" + +echo "Connecting customer_lnd → mint_lnd..." +lnd_post "$CUSTOMER_LND_URL" "/v1/peers" \ + "{\"addr\":{\"pubkey\":\"$MINT_PUBKEY\",\"host\":\"mint_lnd:9735\"},\"perm\":false}" "" || true + +echo "Opening channel customer_lnd → mint_lnd (2 000 000 sats)..." +lnd_post "$CUSTOMER_LND_URL" "/v1/channels" \ + "{\"node_pubkey_string\":\"$MINT_PUBKEY\",\"local_funding_amount\":2000000}" "" | jq . + +# ── channel: customer_lnd → lnd_bitcoin (BTCPay) ───────────────────────────── + +echo "Getting lnd_bitcoin pubkey..." +BTCPAY_PUBKEY=$(lnd_get "$BTCPAY_LND_URL" "/v1/getinfo" "" | jq -r '.identity_pubkey') +echo " lnd_bitcoin pubkey: $BTCPAY_PUBKEY" + +echo "Connecting customer_lnd → lnd_bitcoin..." +lnd_post "$CUSTOMER_LND_URL" "/v1/peers" \ + "{\"addr\":{\"pubkey\":\"$BTCPAY_PUBKEY\",\"host\":\"lnd_bitcoin:9735\"},\"perm\":false}" "" || true + +echo "Opening channel customer_lnd → lnd_bitcoin (2 000 000 sats)..." +lnd_post "$CUSTOMER_LND_URL" "/v1/channels" \ + "{\"node_pubkey_string\":\"$BTCPAY_PUBKEY\",\"local_funding_amount\":2000000}" "" | jq . + +# ── confirm channels ────────────────────────────────────────────────────────── + +echo "Mining 6 blocks to confirm channels..." +MINE_ADDR=$(btc_rpc '{"jsonrpc":"1.0","id":"1","method":"getnewaddress","params":[]}' | jq -r '.result') +btc_rpc "{\"jsonrpc\":\"1.0\",\"id\":\"1\",\"method\":\"generatetoaddress\",\"params\":[6,\"$MINE_ADDR\"]}" > /dev/null + +echo "Waiting for channels to become active..." +for i in $(seq 1 60); do + ACTIVE=$(lnd_get "$CUSTOMER_LND_URL" "/v1/channels" "" | jq '[.channels[]? | select(.active==true)] | length') + echo " active channels: $ACTIVE" + [ "$ACTIVE" -ge 2 ] 2>/dev/null && break + [ $((i % 5)) -eq 0 ] && \ + btc_rpc "{\"jsonrpc\":\"1.0\",\"id\":\"1\",\"method\":\"generatetoaddress\",\"params\":[1,\"$MINE_ADDR\"]}" > /dev/null + sleep 3 +done + +echo "=== Channel setup complete ===" +echo " customer_lnd → mint_lnd : open" +echo " customer_lnd → lnd_bitcoin : open" diff --git a/integration-tests/btcpay/docker-compose.yml b/integration-tests/btcpay/docker-compose.yml new file mode 100644 index 000000000..e17b1818d --- /dev/null +++ b/integration-tests/btcpay/docker-compose.yml @@ -0,0 +1,256 @@ +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 + + # dedicated lnd for cdk-mint — macaroons ON, no TLS + # shares the same bitcoind + nbxplorer as BTCPay but lives in its own container + mint_lnd: + 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://mint_lnd:8080 + LND_EXTRA_ARGS: | + restlisten=mint_lnd:8080 + rpclisten=127.0.0.1:10008 + rpclisten=mint_lnd:10009 + bitcoin.node=bitcoind + bitcoind.rpchost=bitcoind:18443 + bitcoind.rpcuser=btcpay + bitcoind.rpcpass=btcpay + bitcoind.zmqpubrawblock=tcp://bitcoind:28332 + bitcoind.zmqpubrawtx=tcp://bitcoind:28333 + externalip=mint_lnd:9735 + bitcoin.defaultchanconfs=1 + no-rest-tls=1 + tlsextradomain=mint_lnd + expose: + - "8080" + - "9735" + - "10009" + volumes: + - mint-lnd-data:/data + - bitcoind-data:/deps/.bitcoin + healthcheck: + test: ["CMD-SHELL", "test -f /data/data/chain/bitcoin/regtest/admin.macaroon"] + interval: 5s + timeout: 5s + retries: 60 + start_period: 10s + depends_on: + - bitcoind + - nbxplorer + + # simulates a customer that pays CDK mint invoices to receive Cashu tokens + customer_lnd: + 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://customer_lnd:8080 + LND_EXTRA_ARGS: | + restlisten=customer_lnd:8080 + rpclisten=127.0.0.1:10008 + rpclisten=customer_lnd:10009 + bitcoin.node=bitcoind + bitcoind.rpchost=bitcoind:18443 + bitcoind.rpcuser=btcpay + bitcoind.rpcpass=btcpay + bitcoind.zmqpubrawblock=tcp://bitcoind:28332 + bitcoind.zmqpubrawtx=tcp://bitcoind:28333 + externalip=customer_lnd:9735 + bitcoin.defaultchanconfs=1 + no-macaroons=1 + no-rest-tls=1 + ports: + - "35532:8080" + expose: + - "8080" + - "9735" + volumes: + - customer-lnd-data:/root/.lnd + - bitcoind-data:/deps/.bitcoin + depends_on: + - bitcoind + - nbxplorer + + # cdk mint backed by mint_lnd; used by CDK integration tests and trusted by BTCPay + cdk-mint: + platform: linux/amd64 + image: cashubtc/mintd:latest + restart: unless-stopped + ports: + - "3338:3338" + expose: + - "3338" + environment: + CDK_MINTD_LN_BACKEND: lnd + CDK_MINTD_LND_ADDRESS: https://mint_lnd:10009 + CDK_MINTD_LND_MACAROON_FILE: /lnd/data/chain/bitcoin/regtest/admin.macaroon + CDK_MINTD_LND_CERT_FILE: /lnd/tls.cert + CDK_MINTD_LISTEN_HOST: 0.0.0.0 + CDK_MINTD_LISTEN_PORT: 3338 + CDK_MINTD_MNEMONIC: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + CDK_MINTD_DATABASE: sqlite + CDK_MINTD_INPUT_FEE_PPK: 0 + volumes: + - mint-lnd-data:/lnd + depends_on: + mint_lnd: + condition: service_healthy + + # sets up Lightning channels: customer_lnd --> mint_lnd + # must complete before tests run (btcpayserver does NOT depend on it — channels are for CDK only) + channel-setup: + image: alpine:3.20 + command: ["/bin/sh", "-c", "apk add --no-cache curl jq xxd >/dev/null 2>&1 && /scripts/channel-setup.sh"] + environment: + CUSTOMER_LND_URL: "http://customer_lnd:8080" + MINT_LND_URL: "http://mint_lnd:8080" + BTCPAY_LND_URL: "http://lnd_bitcoin:8080" + MINT_MACAROON_PATH: "/mint_lnd_data/data/chain/bitcoin/regtest/admin.macaroon" + BTC_RPC_URL: "http://bitcoind:18443" + BTC_RPC_USER: "btcpay" + BTC_RPC_PASS: "btcpay" + volumes: + - "./channel-setup.sh:/scripts/channel-setup.sh:ro" + - "mint-lnd-data:/mint_lnd_data:ro" + depends_on: + mint_lnd: + condition: service_healthy + customer_lnd: + condition: service_started + +volumes: + btcpay-data: + postgres-data: + nbxplorer-data: + plugin-data: + bitcoind-data: + lnd-bitcoin-data: + mint-lnd-data: + customer-lnd-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 000000000..9349a6ccd --- /dev/null +++ b/integration-tests/btcpay/provision.sh @@ -0,0 +1,209 @@ +#!/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 + +# Create Cashu wallet +echo "Creating Cashu wallet..." +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + "$BASE_URL/api/v1/stores/$STORE_ID/cashu/wallet" \ + -H "Authorization: token $API_KEY" \ + -H "Content-Type: application/json") +HTTP_CODE=$(echo "$RESPONSE" | tail -1) +BODY=$(echo "$RESPONSE" | sed '$d') + +if [ "$HTTP_CODE" == "200" ]; then + echo "Cashu wallet created!" + CASHU_MNEMONIC=$(echo "$BODY" | jq -r '.mnemonic // empty') + if [ -n "$CASHU_MNEMONIC" ]; then + echo "Mnemonic: $CASHU_MNEMONIC" + fi +else + echo "WARNING: Could not create Cashu wallet (HTTP $HTTP_CODE): $BODY" + echo " (Cashu plugin may not be installed)" +fi + +# Enable Cashu payment method and trust the local CDK mint +# BTCPay reaches cdk-mint via Docker hostname; tests reach it on localhost:3338 +CDK_MINT_DOCKER_URL="http://cdk-mint:3338" +echo "Enabling Cashu payment method (trusting $CDK_MINT_DOCKER_URL)..." +RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \ + "$BASE_URL/api/v1/stores/$STORE_ID/cashu" \ + -H "Authorization: token $API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"enabled\": true, + \"paymentModel\": \"TrustedMintsOnly\", + \"trustedMintsUrls\": [\"$CDK_MINT_DOCKER_URL\"] + }") +HTTP_CODE=$(echo "$RESPONSE" | tail -1) +BODY=$(echo "$RESPONSE" | sed '$d') + +CASHU_ENABLED="false" +if [ "$HTTP_CODE" == "200" ]; then + CASHU_ENABLED=$(echo "$BODY" | jq -r '.enabled // false') + CASHU_PAYMENT_MODEL=$(echo "$BODY" | jq -r '.paymentModel // empty') + echo "Cashu enabled: $CASHU_ENABLED (model: $CASHU_PAYMENT_MODEL)" +else + echo "WARNING: Could not enable Cashu (HTTP $HTTP_CODE): $BODY" +fi + +# Wait for Cashu payment method to appear on invoices +if [ "$CASHU_ENABLED" == "true" ]; then + echo "Waiting for Cashu payment method to be available on invoices..." + for i in {1..20}; do + RESPONSE=$(curl -s -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"}') + TEST_INVOICE_ID=$(echo "$RESPONSE" | jq -r '.id // empty') + + if [ -n "$TEST_INVOICE_ID" ]; then + PM_RESPONSE=$(curl -s \ + "$BASE_URL/api/v1/stores/$STORE_ID/invoices/$TEST_INVOICE_ID/payment-methods" \ + -H "Authorization: token $API_KEY") + HAS_CASHU=$(echo "$PM_RESPONSE" | jq '[.[] | select(.paymentMethodId | test("Cashu"; "i"))] | length') + + if [ "$HAS_CASHU" -gt 0 ]; then + echo "Cashu payment method is available on invoices!" + # Invalidate test invoice + curl -s -X POST \ + "$BASE_URL/api/v1/stores/$STORE_ID/invoices/$TEST_INVOICE_ID/status" \ + -H "Authorization: token $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"status": "Invalid"}' > /dev/null 2>&1 + break + fi + fi + + if [ "$i" -eq 20 ]; then + echo "WARNING: Cashu payment method not appearing on invoices after 40s" + fi + echo " Not yet ($i/20)..." + sleep 2 + done +fi + +# Output to properties file +OUTPUT_FILE="btcpay_env.properties" + +cat > "$OUTPUT_FILE" <