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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/btcpay-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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: Build and Start BTCPay Server
working-directory: integration-tests/btcpay
run: |
docker compose up -d
# Give it some time to start up initially
sleep 10

- name: Provision BTCPay Server
working-directory: integration-tests/btcpay
run: |
sudo apt-get update && sudo apt-get install -y jq curl
chmod +x provision.sh
./provision.sh

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Run Integration Tests
run: ./gradlew testDebugUnitTest --tests "com.electricdreams.numo.core.payment.impl.BtcPayPaymentServiceIntegrationTest"

- name: Cleanup
if: always()
working-directory: integration-tests/btcpay
run: docker compose down -v
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
local.properties
.aider*
.vscode
release
release
btcpay_env.properties
11 changes: 11 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,17 @@
android:value=".feature.settings.SettingsActivity" />
</activity>

<activity android:name="com.electricdreams.numo.feature.settings.BtcPaySettingsActivity"
android:exported="false"
android:label="@string/btcpay_settings_title"
android:theme="@style/Theme.Numo"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:parentActivityName=".feature.settings.SettingsActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".feature.settings.SettingsActivity" />
</activity>

<activity android:name="com.electricdreams.numo.feature.autowithdraw.AutoWithdrawSettingsActivity"
android:exported="false"
android:label="@string/auto_withdraw_title"
Expand Down
152 changes: 152 additions & 0 deletions app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ import com.electricdreams.numo.payment.NostrPaymentHandler
import com.electricdreams.numo.payment.PaymentTabManager
import com.electricdreams.numo.ui.util.QrCodeGenerator
import com.electricdreams.numo.feature.autowithdraw.AutoWithdrawManager
import com.electricdreams.numo.core.payment.PaymentService
import com.electricdreams.numo.core.payment.PaymentServiceFactory
import com.electricdreams.numo.core.payment.PaymentState
import com.electricdreams.numo.core.payment.impl.BtcPayPaymentService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

Expand Down Expand Up @@ -74,11 +79,19 @@ class PaymentRequestActivity : AppCompatActivity() {
// Tab manager for Cashu/Lightning tab switching
private lateinit var tabManager: PaymentTabManager

// Payment service abstraction (Local CDK or BTCPay)
private lateinit var paymentService: PaymentService

// Payment handlers
private var nostrHandler: NostrPaymentHandler? = null
private var lightningHandler: LightningMintHandler? = null
private var lightningStarted = false

// BTCPay payment tracking
private var btcPayPaymentId: String? = null
private var btcPayCashuPR: String? = null
private var btcPayPollingActive = false

// Lightning quote info for history
private var lightningInvoice: String? = null
private var lightningQuoteId: String? = null
Expand Down Expand Up @@ -366,6 +379,77 @@ class PaymentRequestActivity : AppCompatActivity() {
statusText.visibility = View.VISIBLE
statusText.text = getString(R.string.payment_request_status_preparing)

// Create the payment service (BTCPay or Local)
paymentService = PaymentServiceFactory.create(this)

val isBtcPay = paymentService is BtcPayPaymentService

if (isBtcPay) {
initializeBtcPayPaymentRequest()
} else {
initializeLocalPaymentRequest()
}
}

/**
* BTCPay mode: create an invoice via BTCPay Server, display the bolt11 /
* cashu QR codes from the response, and poll for payment status.
*/
private fun initializeBtcPayPaymentRequest() {
uiScope.launch {
val result = paymentService.createPayment(paymentAmount, "Payment of $paymentAmount sats")
result.onSuccess { payment ->
btcPayPaymentId = payment.paymentId

// Show Cashu QR (cashuPR from BTCNutServer)
if (!payment.cashuPR.isNullOrBlank()) {
btcPayCashuPR = payment.cashuPR
try {
val qrBitmap = QrCodeGenerator.generate(payment.cashuPR, 512)
cashuQrImageView.setImageBitmap(qrBitmap)
} catch (e: Exception) {
Log.e(TAG, "Error generating BTCPay Cashu QR: ${e.message}", e)
}

// 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()
}
}

// Show Lightning QR
if (!payment.bolt11.isNullOrBlank()) {
lightningInvoice = payment.bolt11
try {
val qrBitmap = QrCodeGenerator.generate(payment.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
}

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)
statusText.text = getString(R.string.payment_request_status_error_generic, error.message ?: "Unknown error")
}
}
}

/**
* 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()
Expand Down Expand Up @@ -416,6 +500,43 @@ class PaymentRequestActivity : AppCompatActivity() {
// (see TabSelectionListener.onLightningTabSelected())
}

/**
* 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
handlePaymentError("Invoice expired")
}
PaymentState.FAILED -> {
btcPayPollingActive = false
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")
Expand Down Expand Up @@ -596,6 +717,33 @@ class PaymentRequestActivity : AppCompatActivity() {
// and redemption to CashuPaymentHelper.
uiScope.launch {
try {
// If using BTCPay, we must send the token to the POST endpoint
// specified in the original payment request.
if (paymentService is BtcPayPaymentService) {
val pr = btcPayCashuPR
if (pr != null) {
val postUrl = CashuPaymentHelper.getPostUrl(pr)
val requestId = CashuPaymentHelper.getId(pr)

if (postUrl != null && requestId != null) {
Log.d(TAG, "Redeeming NFC token via BTCPay NUT-18 POST endpoint")
val result = (paymentService as BtcPayPaymentService).redeemTokenToPostEndpoint(
token, requestId, postUrl
)
result.onSuccess {
withContext(Dispatchers.Main) {
handleLightningPaymentSuccess()
}
}.onFailure { e ->
throw Exception("BTCPay redemption failed: ${e.message}")
}
return@launch
} else {
Log.w(TAG, "BTCPay PR missing postUrl or id, falling back to local flow (likely to fail)")
}
}
}

val paymentId = pendingPaymentId
val paymentContext = com.electricdreams.numo.payment.SwapToLightningMintManager.PaymentContext(
paymentId = paymentId,
Expand Down Expand Up @@ -820,6 +968,10 @@ class PaymentRequestActivity : AppCompatActivity() {
// a safety net for any paths that might reach cleanup without having
// called [beginTerminalOutcome] explicitly.
hasTerminalOutcome = true

// Stop BTCPay polling
btcPayPollingActive = false

// Stop Nostr handler
nostrHandler?.stop()
nostrHandler = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -220,6 +223,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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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<PaymentData>

/**
* Poll the current status of a payment.
*
* @param paymentId The id returned in [PaymentData.paymentId]
*/
suspend fun checkPaymentStatus(paymentId: String): WalletResult<PaymentState>

/**
* 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<RedeemResult>

/**
* Whether the service is ready to create payments.
*/
fun isReady(): Boolean
}
Loading