Skip to content
Open
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
44 changes: 44 additions & 0 deletions .github/workflows/btcpay-integration.yml
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are any of these changes needed?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean the changes to this file in ff3d612

Original file line number Diff line number Diff line change
@@ -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
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 @@ -482,6 +482,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
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ class PaymentFailureActivity : AppCompatActivity() {
handleTryAgain()
}

// Hide "Try Again" if there are no resumable pending payments
val hasPending = PaymentsHistoryActivity.getPaymentHistory(this).any { it.isPending() }
if (!hasPending) {
tryAgainButton.visibility = View.GONE
}

// Start the error animation after a short delay
errorIcon.postDelayed({
animateErrorIcon()
Expand Down
211 changes: 209 additions & 2 deletions app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
package com.electricdreams.numo
import com.electricdreams.numo.R

import android.animation.AnimatorSet
import android.animation.ObjectAnimator
Expand Down Expand Up @@ -41,8 +40,13 @@ import com.electricdreams.numo.ui.animation.NfcPaymentAnimationView
import com.electricdreams.numo.ui.util.QrCodeGenerator
import com.electricdreams.numo.feature.autowithdraw.AutoWithdrawManager
import com.electricdreams.numo.feature.settings.DeveloperPrefs
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 All @@ -61,6 +65,8 @@ class PaymentRequestActivity : AppCompatActivity() {
private lateinit var shareButton: View
private lateinit var lightningLoadingSpinner: View
private lateinit var lightningLogoCard: View
private lateinit var cashuLoadingSpinner: View
private lateinit var cashuLogoCard: View

// NFC Animation views
private lateinit var nfcAnimationContainer: View
Expand Down Expand Up @@ -95,11 +101,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 @@ -161,7 +175,9 @@ class PaymentRequestActivity : AppCompatActivity() {
shareButton = findViewById(R.id.share_button)
lightningLoadingSpinner = findViewById(R.id.lightning_loading_spinner)
lightningLogoCard = findViewById(R.id.lightning_logo_card)

cashuLoadingSpinner = findViewById(R.id.cashu_loading_spinner)
cashuLogoCard = findViewById(R.id.cashu_logo_card)

// NFC Animation views
nfcAnimationContainer = findViewById(R.id.nfc_animation_container)
nfcAnimationView = findViewById(R.id.nfc_animation_view)
Expand Down Expand Up @@ -422,6 +438,124 @@ 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() {
// Show spinner, hide QR and logo while createPayment() runs
cashuQrImageView.visibility = View.INVISIBLE
cashuLogoCard.visibility = View.GONE
cashuLoadingSpinner.visibility = View.VISIBLE

uiScope.launch {
val result = paymentService.createPayment(paymentAmount, "Payment of $paymentAmount sats")
result.onSuccess { payment ->
btcPayPaymentId = payment.paymentId

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()
Expand Down Expand Up @@ -476,6 +610,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")
Expand Down Expand Up @@ -671,6 +848,33 @@ class PaymentRequestActivity : AppCompatActivity() {
Log.d(TAG, "NFC token received, cancelled safety timeout")

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 @@ -903,6 +1107,9 @@ class PaymentRequestActivity : AppCompatActivity() {
hasTerminalOutcome = true
cancelNfcSafetyTimeout()

// Stop BTCPay polling
btcPayPollingActive = false

// Stop Nostr handler
nostrHandler?.stop()
nostrHandler = null
Expand Down
Loading