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