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" <