From 051cfffa62a1b68be7e566c195a29a6ce3509469 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 21 May 2026 12:51:05 -0500 Subject: [PATCH 1/4] feat: add btcpay wallet connection --- .../to/bitkit/services/OnchainServiceTests.kt | 88 ++++ app/src/main/AndroidManifest.xml | 17 + .../to/bitkit/models/SamRockSetupRequest.kt | 347 ++++++++++++++ .../to/bitkit/repositories/SamRockRepo.kt | 255 ++++++++++ .../java/to/bitkit/services/CoreService.kt | 20 + app/src/main/java/to/bitkit/ui/ContentView.kt | 2 + .../main/java/to/bitkit/ui/MainActivity.kt | 7 +- .../java/to/bitkit/ui/components/SheetHost.kt | 2 + .../ui/screens/scanner/QrScanningScreen.kt | 5 +- .../wallets/send/SendRecipientScreen.kt | 7 +- .../bitkit/ui/sheets/BTCPayConnectionSheet.kt | 227 +++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 110 ++++- app/src/main/res/values/strings.xml | 20 + .../bitkit/models/SamRockSetupRequestTest.kt | 296 ++++++++++++ .../to/bitkit/repositories/SamRockRepoTest.kt | 452 ++++++++++++++++++ .../to/bitkit/ui/MainActivityLaunchKeyTest.kt | 58 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 284 ++++++++++- changelog.d/next/953.added.md | 1 + gradle/libs.versions.toml | 2 +- 19 files changed, 2184 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SamRockRepo.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt create mode 100644 app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt create mode 100644 changelog.d/next/953.added.md diff --git a/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt b/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt index aaabf6b96..a766dcbfc 100644 --- a/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt @@ -1,6 +1,7 @@ package to.bitkit.services import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AddressType import kotlinx.coroutines.runBlocking import org.junit.Before @@ -11,6 +12,7 @@ import to.bitkit.models.toDerivationPath import to.bitkit.test.annotations.CoreServiceIntegration import to.bitkit.test.annotations.DeviceIntegration import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -146,6 +148,86 @@ class OnchainServiceTests { assertNotNull(result.publicKey) } + @Test + fun testDeriveBitcoinDescriptorsForSupportedAccountTypes(): Unit = runBlocking { + val cases = listOf( + DescriptorCase(AccountType.LEGACY, "pkh([", "44"), + DescriptorCase(AccountType.WRAPPED_SEGWIT, "sh(wpkh([", "49"), + DescriptorCase(AccountType.NATIVE_SEGWIT, "wpkh([", "84"), + DescriptorCase(AccountType.TAPROOT, "tr([", "86"), + ) + + cases.forEach { descriptorCase -> + assertDescriptorShape( + descriptorCase = descriptorCase, + network = Network.BITCOIN, + coinType = "0", + publicKeyPrefix = "xpub", + ) + } + } + + @Test + fun testDeriveRegtestDescriptorsForSupportedAccountTypes(): Unit = runBlocking { + val cases = listOf( + DescriptorCase(AccountType.LEGACY, "pkh([", "44"), + DescriptorCase(AccountType.WRAPPED_SEGWIT, "sh(wpkh([", "49"), + DescriptorCase(AccountType.NATIVE_SEGWIT, "wpkh([", "84"), + DescriptorCase(AccountType.TAPROOT, "tr([", "86"), + ) + + cases.forEach { descriptorCase -> + assertDescriptorShape( + descriptorCase = descriptorCase, + network = Network.REGTEST, + coinType = "1", + publicKeyPrefix = "tpub", + ) + } + } + + @Test + fun testDeriveBitcoinNativeSegwitDescriptorWithPassphrase(): Unit = runBlocking { + val descriptor = onchainService.deriveOnchainDescriptor( + mnemonicPhrase = mnemonic, + network = Network.BITCOIN, + bip39Passphrase = "TREZOR", + accountType = AccountType.NATIVE_SEGWIT, + accountIndex = 0u, + ) + val descriptorWithoutPassphrase = onchainService.deriveOnchainDescriptor( + mnemonicPhrase = mnemonic, + network = Network.BITCOIN, + bip39Passphrase = null, + accountType = AccountType.NATIVE_SEGWIT, + accountIndex = 0u, + ) + + assertTrue(descriptor.startsWith("wpkh([")) + assertTrue(descriptor.contains("/84'/0'/0']xpub")) + assertTrue(descriptor.contains("/0/*")) + assertFalse(descriptor == descriptorWithoutPassphrase) + } + + private suspend fun assertDescriptorShape( + descriptorCase: DescriptorCase, + network: Network, + coinType: String, + publicKeyPrefix: String, + ) { + val descriptor = onchainService.deriveOnchainDescriptor( + mnemonicPhrase = mnemonic, + network = network, + bip39Passphrase = null, + accountType = descriptorCase.accountType, + accountIndex = 0u, + ) + + assertTrue(descriptor.startsWith(descriptorCase.prefix)) + assertTrue(descriptor.contains("/${descriptorCase.purpose}'/$coinType'/0']$publicKeyPrefix")) + assertTrue(descriptor.contains("/0/*")) + } + @Test fun testDeriveAddresses(): Unit = runBlocking { val derivationPath = AddressType.P2WPKH.toDerivationPath(network = Network.BITCOIN) @@ -233,3 +315,9 @@ class OnchainServiceTests { } } + +private data class DescriptorCase( + val accountType: AccountType, + val prefix: String, + val purpose: String, +) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78b629540..075d512b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,6 +127,23 @@ + + + + + + + + + + + diff --git a/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt b/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt new file mode 100644 index 000000000..8a46aedff --- /dev/null +++ b/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt @@ -0,0 +1,347 @@ +package to.bitkit.models + +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Locale + +data class SamRockSetupRequest( + val postUrl: String, + val storeId: String, + val otp: String, + val requestedMethods: Set, + val hasUnknownMethods: Boolean, + val hostDisplayName: String, + val logDescription: String, +) { + companion object { + @Suppress("CyclomaticComplexMethod", "ReturnCount") + fun parse(raw: String): SamRockSetupRequest? { + val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase(Locale.US) ?: return null + val host = uri.host ?: return null + if (uri.rawUserInfo != null) return null + + val isAllowedScheme = scheme == HTTPS_SCHEME || scheme == HTTP_SCHEME && isLocalOrPrivateHost(host) + if (!isAllowedScheme) return null + + val pathComponents = uri.decodedPathComponents() + ?: return null + + if (pathComponents.size != EXPECTED_PATH_COMPONENTS) return null + if (pathComponents[0] != PLUGINS_PATH_COMPONENT) return null + if (!pathComponents[2].equals(SAMROCK_PATH_COMPONENT, ignoreCase = true)) return null + if (!pathComponents[3].equals(PROTOCOL_PATH_COMPONENT, ignoreCase = true)) return null + + val queryItems = runCatching { parseQuery(uri.rawQuery ?: return null) }.getOrNull() ?: return null + val otp = queryItems.firstValue(OTP_QUERY_KEY)?.takeIf { it.isNotBlank() } ?: return null + val setup = queryItems.firstValue(SETUP_QUERY_KEY) + val parsedMethods = parseMethods(setup) + + return SamRockSetupRequest( + postUrl = buildPostUrl(uri, setup, otp), + storeId = pathComponents[1], + otp = otp, + requestedMethods = parsedMethods.methods, + hasUnknownMethods = parsedMethods.hasUnknownMethods, + hostDisplayName = uri.hostDisplayName(), + logDescription = uri.logDescription(), + ) + } + + fun sanitizedDescription(raw: String): String? { + val trimmed = raw.trim() + val uri = runCatching { URI(trimmed) }.getOrNull() + if (uri?.hasAuthority() == true && uri.isSamRockProtocolPath()) return uri.logDescription() + + return trimmed.sanitizedSamRockLikeDescription() + } + + fun sanitizedLaunchKey(raw: String): String? { + return sanitizedDescription(raw)?.let { "$it#${raw.sha256Prefix()}" } + } + + fun isProtocolUrl(raw: String): Boolean { + return sanitizedDescription(raw) != null + } + + fun isPublicHttpProtocolUrl(raw: String): Boolean { + val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return false + val scheme = uri.scheme?.lowercase(Locale.US) ?: return false + val host = uri.host ?: return false + + return scheme == HTTP_SCHEME && + uri.isSamRockProtocolPath() && + !isLocalOrPrivateHost(host) + } + + private fun parseMethods(setup: String?): ParsedSamRockMethods { + val values = setup + ?.split(SETUP_METHOD_SEPARATOR) + ?.map { it.trim().lowercase(Locale.US) } + ?.filter { it.isNotEmpty() } + .orEmpty() + + if (values.isEmpty()) { + return ParsedSamRockMethods( + methods = setOf(SamRockPaymentMethod.ALL), + hasUnknownMethods = false, + ) + } + + val methods = values.mapNotNull(SamRockPaymentMethod::fromProtocolValue).toSet() + return ParsedSamRockMethods( + methods = methods, + hasUnknownMethods = values.any { SamRockPaymentMethod.fromProtocolValue(it) == null }, + ) + } + + private fun buildPostUrl( + uri: URI, + setup: String?, + otp: String, + ): String { + val query = buildList { + setup?.let { add("$SETUP_QUERY_KEY=${encodeQueryValue(it)}") } + add("$OTP_QUERY_KEY=${encodeQueryValue(otp)}") + }.joinToString("&") + + val authority = uri.rawAuthority ?: uri.host + return "${uri.scheme}://$authority${uri.rawPath}?$query" + } + + private fun parseQuery(rawQuery: String): List> { + return rawQuery + .split("&") + .filter { it.isNotEmpty() } + .map { + val parts = it.split("=", limit = 2) + decode(parts[0]) to decode(parts.getOrElse(1) { "" }) + } + } + + private fun List>.firstValue(key: String): String? { + return firstOrNull { it.first.equals(key, ignoreCase = true) }?.second + } + + private fun URI.hostDisplayName(): String { + val portSuffix = port.takeIf { it != NO_PORT }?.let { ":$it" }.orEmpty() + return "$host$portSuffix" + } + + private fun URI.logDescription(): String { + return "$scheme://${hostWithPort()}$rawPath" + } + + private fun URI.hostWithPort(): String { + val formattedHost = host?.takeIf { ":" in it }?.let { "[$it]" } ?: host.orEmpty() + val portSuffix = port.takeIf { it != NO_PORT }?.let { ":$it" }.orEmpty() + return "$formattedHost$portSuffix" + } + + private fun URI.isSamRockProtocolPath(): Boolean { + val pathComponents = decodedPathComponents() + ?: return false + + return pathComponents.size == EXPECTED_PATH_COMPONENTS && + pathComponents[0] == PLUGINS_PATH_COMPONENT && + pathComponents[2].equals(SAMROCK_PATH_COMPONENT, ignoreCase = true) && + pathComponents[3].equals(PROTOCOL_PATH_COMPONENT, ignoreCase = true) + } + + private fun URI.hasAuthority(): Boolean { + return scheme != null && host != null + } + + private fun String.sanitizedSamRockLikeDescription(): String? { + val queryStart = indexOfAny(SENSITIVE_URL_DELIMITERS) + val withoutQuery = if (queryStart == NOT_FOUND) this else substring(0, queryStart) + val normalized = withoutQuery.lowercase(Locale.US) + val isSamRockLikePath = PLUGINS_PATH_MARKER in normalized && + SAMROCK_PROTOCOL_PATH_MARKER in normalized + + if (!isSamRockLikePath) return null + + return runCatching { URI(withoutQuery) } + .getOrNull() + ?.takeIf { it.hasAuthority() } + ?.logDescription() + ?: withoutQuery.stripUserInfoFromAuthority() + } + + private fun String.stripUserInfoFromAuthority(): String { + val schemeEnd = indexOf(SCHEME_SEPARATOR) + if (schemeEnd == NOT_FOUND) return this + + val authorityStart = schemeEnd + SCHEME_SEPARATOR.length + val pathStart = indexOf(PATH_SEPARATOR, startIndex = authorityStart) + .takeUnless { it == NOT_FOUND } + ?: length + val authority = substring(authorityStart, pathStart) + val safeAuthority = authority.substringAfterLast(USER_INFO_SEPARATOR) + + return substring(0, authorityStart) + safeAuthority + substring(pathStart) + } + + private fun URI.decodedPathComponents(): List? { + return runCatching { + rawPath + ?.split(PATH_SEPARATOR) + ?.filter { it.isNotBlank() } + ?.map(::decode) + }.getOrNull() + } + + private fun decode(value: String): String { + return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8.name()) + } + + private fun encodeQueryValue(value: String): String { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20") + } + + private fun String.sha256Prefix(): String { + val bytes = MessageDigest.getInstance(SHA_256_ALGORITHM).digest(trim().toByteArray(StandardCharsets.UTF_8)) + return bytes.take(LAUNCH_KEY_HASH_BYTES).joinToString("") { "%02x".format(it) } + } + + private fun isLocalOrPrivateHost(host: String): Boolean { + val normalized = host + .removePrefix("[") + .removeSuffix("]") + .lowercase(Locale.US) + + return normalized.isLocalHost() || + normalized.isPrivateIpv6Literal() || + normalized.isPrivateIpv4Literal() + } + + private fun String.isLocalHost(): Boolean { + return this == LOCALHOST || endsWith(LOCAL_HOST_SUFFIX) || this == IPV6_LOOPBACK + } + + private fun String.isPrivateIpv6Literal(): Boolean { + return isIpv6Literal() && IPV6_PRIVATE_PREFIXES.any(::startsWith) + } + + @Suppress("MagicNumber") + private fun String.isPrivateIpv4Literal(): Boolean { + val octets = split(".").map { + it.toIntOrNull() ?: return false + } + if (octets.size != IPV4_OCTET_COUNT) return false + if (octets.any { it !in IPV4_OCTET_RANGE }) return false + + return when (octets[0]) { + 10, 127 -> true + 172 -> octets[1] in 16..31 + 192 -> octets[1] == 168 + 169 -> octets[1] == 254 + else -> false + } + } + + private fun String.isIpv6Literal(): Boolean = ":" in this + + private const val HTTP_SCHEME = "http" + private const val HTTPS_SCHEME = "https" + private const val LOCALHOST = "localhost" + private const val LOCAL_HOST_SUFFIX = ".local" + private const val IPV6_LOOPBACK = "::1" + private const val IPV6_LINK_LOCAL_PREFIX = "fe80:" + private const val IPV6_UNIQUE_LOCAL_FC_PREFIX = "fc" + private const val IPV6_UNIQUE_LOCAL_FD_PREFIX = "fd" + private val IPV6_PRIVATE_PREFIXES = listOf( + IPV6_LINK_LOCAL_PREFIX, + IPV6_UNIQUE_LOCAL_FC_PREFIX, + IPV6_UNIQUE_LOCAL_FD_PREFIX, + ) + private const val IPV4_OCTET_COUNT = 4 + private val IPV4_OCTET_RANGE = 0..255 + private const val NO_PORT = -1 + private const val NOT_FOUND = -1 + private const val PATH_SEPARATOR = '/' + private const val SCHEME_SEPARATOR = "://" + private const val USER_INFO_SEPARATOR = '@' + private const val EXPECTED_PATH_COMPONENTS = 4 + private const val PLUGINS_PATH_COMPONENT = "plugins" + private const val SAMROCK_PATH_COMPONENT = "samrock" + private const val PROTOCOL_PATH_COMPONENT = "protocol" + private const val PLUGINS_PATH_MARKER = "/plugins/" + private const val SAMROCK_PROTOCOL_PATH_MARKER = "/samrock/protocol" + private const val OTP_QUERY_KEY = "otp" + private const val SETUP_QUERY_KEY = "setup" + private const val SETUP_METHOD_SEPARATOR = "," + private const val SHA_256_ALGORITHM = "SHA-256" + private const val LAUNCH_KEY_HASH_BYTES = 8 + private val SENSITIVE_URL_DELIMITERS = charArrayOf('?', '#') + } + + val requestsBitcoinOnchain: Boolean + get() = requestedMethods.any { + it == SamRockPaymentMethod.ALL || + it == SamRockPaymentMethod.BTC || + it == SamRockPaymentMethod.BTC_ONCHAIN + } + + val requestsUnsupportedMethods: Boolean + get() = hasUnknownMethods || requestedMethods.any { + it == SamRockPaymentMethod.ALL || + it == SamRockPaymentMethod.LIQUID || + it == SamRockPaymentMethod.LIQUID_ONCHAIN || + it == SamRockPaymentMethod.BTC_LIGHTNING + } +} + +enum class SamRockPaymentMethod( + private val protocolValues: Set, +) { + ALL(setOf("all")), + BTC(setOf("btc")), + BTC_ONCHAIN(setOf("btc-chain")), + LIQUID(setOf("lbtc")), + LIQUID_ONCHAIN(setOf("liquid-chain")), + BTC_LIGHTNING(setOf("btcln", "btc-ln")); + + companion object { + fun fromProtocolValue(value: String): SamRockPaymentMethod? { + return entries.firstOrNull { value in it.protocolValues } + } + } +} + +private data class ParsedSamRockMethods( + val methods: Set, + val hasUnknownMethods: Boolean, +) + +fun String.sanitizedQrLogValue(): String { + return SamRockSetupRequest.sanitizedDescription(this) ?: redactedLogValue() +} + +fun String.sanitizedDeeplinkLogValue(): String { + SamRockSetupRequest.sanitizedDescription(this)?.let { return it } + + val uri = runCatching { URI(trim()) }.getOrNull() ?: return redactedLogValue() + val scheme = uri.scheme ?: return redactedLogValue() + val host = uri.host ?: return scheme + val formattedHost = if (":" in host) "[$host]" else host + val portSuffix = uri.port.takeIf { it != LOG_NO_PORT }?.let { ":$it" }.orEmpty() + + return "$scheme://$formattedHost$portSuffix${uri.rawPath.orEmpty()}" +} + +private fun String.redactedLogValue(): String { + return "redacted#${sha256LogPrefix()}" +} + +private fun String.sha256LogPrefix(): String { + val bytes = MessageDigest.getInstance(LOG_SHA_256_ALGORITHM).digest(trim().toByteArray(StandardCharsets.UTF_8)) + return bytes.take(LOG_HASH_BYTES).joinToString("") { "%02x".format(it) } +} + +private const val LOG_HASH_BYTES = 8 +private const val LOG_NO_PORT = -1 +private const val LOG_SHA_256_ALGORITHM = "SHA-256" diff --git a/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt b/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt new file mode 100644 index 000000000..2291cbe83 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt @@ -0,0 +1,255 @@ +package to.bitkit.repositories + +import android.content.Context +import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.AddressType +import dagger.hilt.android.qualifiers.ApplicationContext +import io.ktor.client.HttpClient +import io.ktor.client.request.accept +import io.ktor.client.request.forms.FormDataContent +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import to.bitkit.R +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.models.DEFAULT_ADDRESS_TYPE +import to.bitkit.models.SamRockSetupRequest +import to.bitkit.models.toAddressType +import to.bitkit.services.CoreService +import to.bitkit.utils.AppError +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@Suppress("LongParameterList") +class SamRockRepo @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val json: Json, + private val keychain: Keychain, + private val settingsStore: SettingsStore, + private val coreService: CoreService, + private val samRockHttpClient: SamRockHttpClient, +) { + companion object { + private const val BITCOIN_METHOD = "BTC" + private const val SAMROCK_VERSION = "1.0" + } + + suspend fun registerBitcoinOnchain(setup: SamRockSetupRequest): Result = withContext(ioDispatcher) { + runCatching { + if (!setup.requestsBitcoinOnchain) { + throw AppError(context.getString(R.string.btcpay__unsupported_text)) + } + + val descriptor = derivePrimaryAddressDescriptor() + val payload = json.encodeToString( + SamRockDescriptorPayload( + version = SAMROCK_VERSION, + btc = SamRockBitcoinDescriptor(descriptor = descriptor) + ) + ) + + val response = runCatching { samRockHttpClient.postDescriptorSetup(setup.postUrl, payload) } + .getOrElse { + throw SamRockTransportError( + message = context.getString(R.string.btcpay__request_error), + cause = it, + ) + } + val envelope = SamRockResponseParser.decode(response.body) + + if (!response.status.isSuccess()) { + throw AppError(envelope?.message ?: response.status.description) + } + + if (envelope == null) { + throw AppError(context.getString(R.string.btcpay__invalid_response)) + } + + when (envelope.success) { + true -> Unit + false -> throw AppError(envelope.message ?: context.getString(R.string.btcpay__setup_failed)) + null -> throw AppError(context.getString(R.string.btcpay__invalid_response)) + } + + val btcResult = envelope.result?.results?.get(BITCOIN_METHOD) + ?: throw AppError(context.getString(R.string.btcpay__missing_result)) + + if (!btcResult.success) { + throw AppError(btcResult.message ?: context.getString(R.string.btcpay__rejected_descriptor)) + } + } + } + + private suspend fun derivePrimaryAddressDescriptor(): String { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw AppError(context.getString(R.string.btcpay__missing_mnemonic)) + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)?.takeIf { it.isNotEmpty() } + val selectedAddressType = settingsStore.data.first().selectedAddressType + + return coreService.onchain.deriveOnchainDescriptor( + mnemonicPhrase = mnemonic, + network = Env.network, + bip39Passphrase = passphrase, + accountType = selectedAddressType.toSamRockAccountType(), + accountIndex = 0u, + ) + } +} + +private class SamRockTransportError( + message: String, + cause: Throwable?, +) : AppError(message, cause) + +@Singleton +class SamRockHttpClient @Inject constructor( + private val httpClient: HttpClient, +) { + companion object { + private const val CHARSET_PARAMETER = "charset" + private const val UTF8_PARAMETER = "utf-8" + } + + suspend fun postDescriptorSetup( + postUrl: String, + payload: String, + ): SamRockHttpResponse { + val response = httpClient.post(postUrl) { + accept(ContentType.Application.Json) + contentType(ContentType.Application.FormUrlEncoded.withParameter(CHARSET_PARAMETER, UTF8_PARAMETER)) + setBody(samRockFormBody(payload)) + } + + return SamRockHttpResponse( + status = response.status, + body = response.bodyAsText(), + ) + } +} + +data class SamRockHttpResponse( + val status: HttpStatusCode, + val body: String, +) + +internal fun samRockFormBody(payload: String): FormDataContent { + return FormDataContent( + Parameters.build { + append(JSON_FORM_FIELD, payload) + } + ) +} + +internal object SamRockResponseParser { + private val parser = Json { ignoreUnknownKeys = true } + + fun decode(body: String): SamRockResponseEnvelope? { + val root = runCatching { parser.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return null + return SamRockResponseEnvelope( + success = root.booleanFor(RESPONSE_SUCCESS_KEYS), + message = root.stringFor(RESPONSE_MESSAGE_KEYS), + result = root.objectFor(RESPONSE_RESULT_KEYS)?.toSetupResponse(), + ) + } + + private fun JsonObject.toSetupResponse(): SamRockSetupResponse { + val resultsObject = objectFor(RESPONSE_RESULTS_KEYS) ?: this + return SamRockSetupResponse( + results = resultsObject.mapNotNull { (key, value) -> + val methodObject = value as? JsonObject ?: return@mapNotNull null + val success = methodObject.booleanFor(RESPONSE_SUCCESS_KEYS) ?: return@mapNotNull null + key to SamRockMethodResponse( + success = success, + message = methodObject.stringFor(RESPONSE_MESSAGE_KEYS), + ) + }.toMap() + ) + } + + private fun JsonObject.objectFor(names: List): JsonObject? { + return elementFor(names) as? JsonObject + } + + private fun JsonObject.booleanFor(names: List): Boolean? { + val primitive = elementFor(names) as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.booleanOrNull + } + + private fun JsonObject.stringFor(names: List): String? { + return (elementFor(names) as? JsonPrimitive)?.contentOrNull + } + + private fun JsonObject.elementFor(names: List): JsonElement? { + return names.firstNotNullOfOrNull { this[it] } + } + + private val RESPONSE_SUCCESS_KEYS = listOf("Success", "success") + private val RESPONSE_MESSAGE_KEYS = listOf("Message", "message") + private val RESPONSE_RESULT_KEYS = listOf("Result", "result") + private val RESPONSE_RESULTS_KEYS = listOf("Results", "results") +} + +internal data class SamRockResponseEnvelope( + val success: Boolean?, + val message: String?, + val result: SamRockSetupResponse?, +) + +internal data class SamRockSetupResponse( + val results: Map, +) + +internal data class SamRockMethodResponse( + val success: Boolean, + val message: String?, +) + +internal fun String?.toSamRockAccountType(): AccountType { + return (this?.toAddressType() ?: DEFAULT_ADDRESS_TYPE).let { + when (it) { + AddressType.P2PKH -> AccountType.LEGACY + AddressType.P2SH -> AccountType.WRAPPED_SEGWIT + AddressType.P2TR -> AccountType.TAPROOT + else -> AccountType.NATIVE_SEGWIT + } + } +} + +@Serializable +private data class SamRockDescriptorPayload( + @SerialName("Version") + val version: String, + @SerialName("BTC") + val btc: SamRockBitcoinDescriptor, +) + +@Serializable +private data class SamRockBitcoinDescriptor( + @SerialName("Descriptor") + val descriptor: String, +) + +private const val JSON_FORM_FIELD = "json" diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index a9a8cd23d..083df7346 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.ActivityTags @@ -28,6 +29,7 @@ import com.synonym.bitkitcore.addTags import com.synonym.bitkitcore.createCjitEntry import com.synonym.bitkitcore.createOrder import com.synonym.bitkitcore.deleteActivityById +import com.synonym.bitkitcore.deriveOnchainDescriptor import com.synonym.bitkitcore.estimateOrderFeeFull import com.synonym.bitkitcore.getActivities import com.synonym.bitkitcore.getActivityById @@ -1825,6 +1827,24 @@ class OnchainService { ) } } + + suspend fun deriveOnchainDescriptor( + mnemonicPhrase: String, + network: Network, + bip39Passphrase: String?, + accountType: AccountType, + accountIndex: UInt, + ): String { + return ServiceQueue.CORE.background { + deriveOnchainDescriptor( + mnemonicPhrase = mnemonicPhrase, + network = network.toCoreNetwork(), + bip39Passphrase = bip39Passphrase, + accountType = accountType, + accountIndex = accountIndex, + ) + } + } } // endregion diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 167ea4f32..e0a0a2efc 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -186,6 +186,7 @@ import to.bitkit.ui.settings.support.ReportIssueScreen import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen +import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet @@ -442,6 +443,7 @@ fun ContentView( onDismiss = { appViewModel.hideSheet() }, ) + is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel) is Sheet.Gift -> GiftSheet(sheet, appViewModel) Sheet.QrScanner -> QrScanningSheet(appViewModel) is Sheet.PubkyAuth -> PubkyAuthApprovalSheet( diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index e8d60f020..4511072fd 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -34,6 +34,7 @@ import to.bitkit.R import to.bitkit.androidServices.LightningNodeService import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.SamRockSetupRequest import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.IsOnlineTracker import to.bitkit.ui.components.ToastOverlay @@ -237,10 +238,12 @@ class MainActivity : FragmentActivity() { } } -private fun Intent?.launchKey(): String? { +internal fun Intent?.launchKey(): String? { this ?: return null return when (action) { - Intent.ACTION_VIEW -> data?.toString() + Intent.ACTION_VIEW -> data?.toString()?.let { + SamRockSetupRequest.sanitizedLaunchKey(it) ?: it + } else -> null } } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 9bfe7edd1..172e8c9c1 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import to.bitkit.models.SamRockSetupRequest import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute @@ -49,6 +50,7 @@ sealed interface Sheet { data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet data object ConnectionClosed : Sheet + data class BTCPayConnection(val setup: SamRockSetupRequest) : Sheet data object QrScanner : Sheet data class PubkyAuth(val authUrl: String) : Sheet diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index 754b9f8ff..f8d09a169 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -62,6 +62,7 @@ import to.bitkit.env.Env import to.bitkit.ext.getClipboardText import to.bitkit.ext.startActivityAppSettings import to.bitkit.models.Toast +import to.bitkit.models.sanitizedQrLogValue import to.bitkit.ui.appViewModel import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -123,7 +124,7 @@ fun QrScanningScreen( QrCodeAnalyzer { result -> if (result.isSuccess) { val qrCode = result.getOrThrow() - Logger.debug("QR code scanned: $qrCode") + Logger.debug("Scanned QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) setScanResult(qrCode) } else { val error = requireNotNull(result.exceptionOrNull()) @@ -359,7 +360,7 @@ private fun processImageFromGallery( for (barcode in barcodes) { barcode.rawValue?.let { qrCode -> onScanSuccess(qrCode) - Logger.info("QR code found $qrCode") + Logger.info("Found QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) return@addOnSuccessListener } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index 26bff26df..ff9819bbf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -60,6 +60,7 @@ import kotlinx.coroutines.withContext import to.bitkit.R import to.bitkit.ext.startActivityAppSettings import to.bitkit.models.Toast +import to.bitkit.models.sanitizedQrLogValue import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB @@ -136,7 +137,7 @@ fun SendRecipientScreen( QrCodeAnalyzer { result -> if (result.isSuccess) { val qrCode = result.getOrThrow() - Logger.debug("QR scanned: '$qrCode'", context = TAG) + Logger.debug("Scanned QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) onEvent(SendEvent.AddressContinue(qrCode)) } else { val error = requireNotNull(result.exceptionOrNull()) @@ -205,7 +206,7 @@ fun SendRecipientScreen( // Gallery picker launchers val handleGalleryScanSuccess = { qrCode: String -> - Logger.debug("QR from gallery: $qrCode", context = TAG) + Logger.debug("Found gallery QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) onEvent(SendEvent.AddressContinue(qrCode)) } @@ -469,7 +470,7 @@ private fun processImageFromGallery( for (barcode in barcodes) { barcode.rawValue?.let { qrCode -> onScanSuccess(qrCode) - Logger.info("QR from gallery: $qrCode", context = TAG) + Logger.info("Found gallery QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG) return@addOnSuccessListener } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt new file mode 100644 index 000000000..8e30bc274 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt @@ -0,0 +1,227 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.SamRockPaymentMethod +import to.bitkit.models.SamRockSetupRequest +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.AppViewModel + +@Composable +fun BTCPayConnectionSheet( + sheet: Sheet.BTCPayConnection, + app: AppViewModel, +) { + Content( + setup = sheet.setup, + onCancel = { app.hideSheet() }, + onConnect = { app.connectBTCPay(sheet.setup) }, + ) +} + +@Composable +private fun Content( + setup: SamRockSetupRequest, + modifier: Modifier = Modifier, + onCancel: () -> Unit = {}, + onConnect: suspend () -> Result = { Result.success(Unit) }, +) { + val scope = rememberCoroutineScope() + val fallbackError = stringResource(R.string.btcpay__request_error) + var isConnecting by remember { mutableStateOf(false) } + var errorText by remember { mutableStateOf(null) } + + Column( + modifier = modifier + .sheetHeight(SheetSize.MEDIUM) + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("BTCPayConnectionSheet") + ) { + SheetTopBar(titleText = stringResource(R.string.btcpay__sheet_title)) + VerticalSpacer(16.dp) + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.weight(1f) + ) { + ConnectionRow( + iconRes = R.drawable.ic_store_front, + title = stringResource(R.string.btcpay__store_label), + subtitle = setup.hostDisplayName, + ) + + BodyM( + text = stringResource(R.string.btcpay__sheet_description), + color = Colors.White64, + ) + + ConnectionRow( + iconRes = R.drawable.ic_btc_circle, + title = stringResource(R.string.btcpay__onchain_label), + subtitle = stringResource(R.string.btcpay__descriptor_label), + ) + + if (setup.requestsUnsupportedMethods) { + ConnectionRow( + iconRes = R.drawable.ic_warning, + title = stringResource(R.string.btcpay__limited_support_label), + subtitle = stringResource(R.string.btcpay__unsupported_note), + iconBackground = Colors.Yellow16, + ) + } + + errorText?.let { + BodyS( + text = it, + color = Colors.Red, + modifier = Modifier.testTag("BTCPayConnectionError") + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + fullWidth = false, + enabled = !isConnecting, + modifier = Modifier + .weight(1f) + .testTag("BTCPayConnectionCancel") + ) + PrimaryButton( + text = stringResource(R.string.common__connect), + onClick = { + scope.launch { + isConnecting = true + errorText = null + onConnect() + .onFailure { errorText = it.message ?: fallbackError } + isConnecting = false + } + }, + isLoading = isConnecting, + fullWidth = false, + modifier = Modifier + .weight(1f) + .testTag("BTCPayConnectionConnect") + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun ConnectionRow( + iconRes: Int, + title: String, + subtitle: String, + modifier: Modifier = Modifier, + iconBackground: Color = Colors.White10, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Colors.White08) + .padding(16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(iconBackground) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + } + HorizontalSpacer(12.dp) + Column( + modifier = Modifier.weight(1f) + ) { + BodyMSB( + text = title, + color = Colors.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + VerticalSpacer(2.dp) + BodyS( + text = subtitle, + color = Colors.White64, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content( + setup = SamRockSetupRequest( + postUrl = "https://example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=abc", + storeId = "store", + otp = "abc", + requestedMethods = setOf(SamRockPaymentMethod.BTC_ONCHAIN, SamRockPaymentMethod.BTC_LIGHTNING), + hasUnknownMethods = false, + hostDisplayName = "example.com", + logDescription = "https://example.com/plugins/store/samrock/protocol", + ) + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 83ac9750a..553e0e948 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -105,12 +105,14 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult +import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.msatFloorOf import to.bitkit.models.safe +import to.bitkit.models.sanitizedDeeplinkLogValue import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork import to.bitkit.models.toTxType @@ -130,6 +132,7 @@ import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo @@ -193,6 +196,7 @@ class AppViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, + private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, private val notificationsSheet: NotificationsTimedSheet, @@ -321,6 +325,7 @@ class AppViewModel @Inject constructor( val currentSheet = _currentSheet.value val isHighPrioritySheetShowing = currentSheet is Sheet.Gift || currentSheet is Sheet.Send || + currentSheet is Sheet.BTCPayConnection || currentSheet is Sheet.LnurlAuth || currentSheet is Sheet.Pin || currentSheet is Sheet.PubkyAuth @@ -1318,7 +1323,12 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean = false, ) { val normalized = data.removeLightningSchemes() - val scanId = if (data.length > 24) "${data.take(11)}…${data.takeLast(11)}" else data + val scanLogInput = SamRockSetupRequest.sanitizedDescription(normalized) ?: data + val scanId = if (scanLogInput.length > 24) { + "${scanLogInput.take(11)}…${scanLogInput.takeLast(11)}" + } else { + scanLogInput + } if (normalized == activeScanInput && activeScanJob?.isActive == true) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) @@ -1611,6 +1621,16 @@ class AppViewModel @Inject constructor( return@withContext } + SamRockSetupRequest.parse(input)?.let { + handleSamRockSetup(it) + return@withContext + } + + if (SamRockSetupRequest.isProtocolUrl(input)) { + handleInvalidSamRockSetup(input) + return@withContext + } + if (input.startsWith("$PUBKYAUTH_SCHEME://")) { clearActiveContactPaymentContext() if (isPaykitEnabled.value) { @@ -1642,8 +1662,9 @@ class AppViewModel @Inject constructor( } } + val safeLogInput = SamRockSetupRequest.sanitizedDescription(input) ?: input val scan = runCatching { coreService.decode(input) } - .onFailure { Logger.error("Failed to decode scan data: '$input'", it, context = TAG) } + .onFailure { Logger.error("Failed to decode scan data: '$safeLogInput'", it, context = TAG) } .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() @@ -1679,6 +1700,38 @@ class AppViewModel @Inject constructor( } } + private suspend fun handleSamRockSetup(setup: SamRockSetupRequest) { + clearActiveContactPaymentContext() + + if (!setup.requestsBitcoinOnchain) { + hideSheet() + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.btcpay__unsupported_title), + description = context.getString(R.string.btcpay__unsupported_text), + testTag = "BTCPayUnsupportedToast", + ) + return + } + + showSheet(Sheet.BTCPayConnection(setup)) + } + + private suspend fun handleInvalidSamRockSetup(input: String) { + clearActiveContactPaymentContext() + hideSheet() + val descriptionRes = when { + SamRockSetupRequest.isPublicHttpProtocolUrl(input) -> R.string.btcpay__unsupported_http_text + else -> R.string.btcpay__invalid_link_text + } + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.btcpay__unsupported_title), + description = context.getString(descriptionRes), + testTag = "BTCPayInvalidSetupToast", + ) + } + private suspend fun handleNonPaymentScan(action: suspend () -> Unit) { clearActiveContactPaymentContext() action() @@ -2662,9 +2715,10 @@ class AppViewModel @Inject constructor( fun onScannerSheetResult(data: String) { val handler = scanResultHandler + val shouldHandleAsProtocol = SamRockSetupRequest.isProtocolUrl(data.removeLightningSchemes()) scanResultHandler = null hideSheet() - if (handler != null) { + if (handler != null && !shouldHandleAsProtocol) { viewModelScope.launch { delay(SCREEN_TRANSITION_DELAY) handler(data) @@ -2684,6 +2738,30 @@ class AppViewModel @Inject constructor( hideSheet() } + suspend fun connectBTCPay(setup: SamRockSetupRequest): Result { + val result = samRockRepo.registerBitcoinOnchain(setup) + result + .onSuccess { + hideSheet() + toast( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.btcpay__success_title), + description = context.getString(R.string.btcpay__success_description), + testTag = "BTCPayConnectedToast", + ) + } + .onFailure { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.btcpay__error_title), + description = it.message ?: context.getString(R.string.btcpay__request_error), + testTag = "BTCPayConnectionErrorToast", + ) + } + + return result + } + fun showSheet(sheetType: Sheet) { viewModelScope.launch { _currentSheet.value?.let { @@ -2832,7 +2910,7 @@ class AppViewModel @Inject constructor( // endregion suspend fun canDecodeClipboard(text: String): Boolean = withContext(bgDispatcher) { - runCatching { coreService.decode(text) }.isSuccess + SamRockSetupRequest.isProtocolUrl(text) || runCatching { coreService.decode(text) }.isSuccess } fun onClipboardAutoRead(data: String) { @@ -2926,7 +3004,7 @@ class AppViewModel @Inject constructor( fun handleDeeplinkIntent(intent: Intent) { if (intent.action != Intent.ACTION_VIEW) return intent.data?.let { uri -> - Logger.debug("Received deeplink: $uri") + Logger.debug("Received deeplink '${uri.toString().sanitizedDeeplinkLogValue()}'", context = TAG) processDeeplink(uri) } } @@ -2938,7 +3016,15 @@ class AppViewModel @Inject constructor( } private fun processDeeplink(uri: Uri) = viewModelScope.launch { - if (uri.toString().contains("recovery-mode")) { + val value = uri.toString() + if (SamRockSetupRequest.isProtocolUrl(value)) { + if (!walletRepo.walletExists()) return@launch + + launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY) + return@launch + } + + if (uri.isRecoveryModeDeeplink()) { lightningRepo.setRecoveryMode(enabled = true) delay(SCREEN_TRANSITION_DELAY) mainScreenEffect( @@ -2964,7 +3050,15 @@ class AppViewModel @Inject constructor( if (!walletRepo.walletExists()) return@launch - launchScan(source = ScanSource.DEEPLINK, data = uri.toString(), startDelay = SCREEN_TRANSITION_DELAY) + launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY) + } + + private fun Uri.isRecoveryModeDeeplink(): Boolean { + val normalizedScheme = scheme?.lowercase() + if (normalizedScheme != BITKIT_SCHEME) return false + + return host == RECOVERY_MODE_DEEPLINK || + pathSegments.singleOrNull() == RECOVERY_MODE_DEEPLINK } private suspend fun handlePubkyAuth(authUrl: String) { @@ -3055,7 +3149,9 @@ class AppViewModel @Inject constructor( private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes + private const val BITKIT_SCHEME = "bitkit" private const val PUBKYAUTH_SCHEME = "pubkyauth" + private const val RECOVERY_MODE_DEEPLINK = "recovery-mode" private val LNURL_WITHDRAW_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d08b19881..569d62ef2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,26 @@ Timeframe Week Year + Primary address type descriptor + BTCPay setup failed + This BTCPay setup link is invalid. + Invalid setup response. + Limited support + Bitkit could not read your recovery phrase. + The store did not return a Bitcoin setup result. + Bitcoin on-chain + The store rejected the Bitcoin descriptor. + Could not prepare the setup request. + Setup failed. + Bitkit will share a Bitcoin on-chain receive descriptor so this BTCPay store can generate addresses for your wallet. + Connect BTCPay + BTCPay store + Bitcoin on-chain receiving is ready for this store. + BTCPay connected + BTCPay setup links must use HTTPS unless they point to a local or private server. + Only Bitcoin on-chain will be connected. Other methods from this QR are not supported in Bitkit yet. + This QR does not request Bitcoin on-chain setup. + Setup not supported Store your bitcoin Back up Buy some bitcoin diff --git a/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt b/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt new file mode 100644 index 000000000..142913036 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt @@ -0,0 +1,296 @@ +package to.bitkit.models + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SamRockSetupRequestTest { + @Test + fun `parse accepts modern setup URL`() { + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store-1/samrock/protocol?setup=btc-chain,btc-ln&otp=secret" + ) + ) + + assertEquals("store-1", setup.storeId) + assertEquals("secret", setup.otp) + assertEquals("btcpay.example.com", setup.hostDisplayName) + assertEquals( + "https://btcpay.example.com/plugins/store-1/samrock/protocol?setup=btc-chain%2Cbtc-ln&otp=secret", + setup.postUrl, + ) + assertTrue(setup.requestsBitcoinOnchain) + assertTrue(setup.requestsUnsupportedMethods) + } + + @Test + fun `parse defaults missing setup to all`() { + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store/samrock/protocol?otp=secret&ignored=true" + ) + ) + + assertEquals(setOf(SamRockPaymentMethod.ALL), setup.requestedMethods) + assertEquals( + "https://btcpay.example.com/plugins/store/samrock/protocol?otp=secret", + setup.postUrl, + ) + assertTrue(setup.requestsBitcoinOnchain) + assertTrue(setup.requestsUnsupportedMethods) + } + + @Test + fun `parse does not default unknown setup methods`() { + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=custom&otp=secret" + ) + ) + + assertTrue(setup.requestedMethods.isEmpty()) + assertTrue(setup.hasUnknownMethods) + assertFalse(setup.requestsBitcoinOnchain) + assertTrue(setup.requestsUnsupportedMethods) + } + + @Test + fun `parse flags unknown methods alongside bitcoin onchain`() { + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain,custom&otp=secret" + ) + ) + + assertEquals(setOf(SamRockPaymentMethod.BTC_ONCHAIN), setup.requestedMethods) + assertTrue(setup.hasUnknownMethods) + assertTrue(setup.requestsBitcoinOnchain) + assertTrue(setup.requestsUnsupportedMethods) + } + + @Test + fun `parse recognizes lightning only as unsupported for this flow`() { + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-ln&otp=secret" + ) + ) + + assertEquals(setOf(SamRockPaymentMethod.BTC_LIGHTNING), setup.requestedMethods) + assertFalse(setup.requestsBitcoinOnchain) + assertTrue(setup.requestsUnsupportedMethods) + } + + @Test + fun `parse recognizes every setup method value`() { + val cases = listOf( + "all" to SamRockPaymentMethod.ALL, + "btc" to SamRockPaymentMethod.BTC, + "btc-chain" to SamRockPaymentMethod.BTC_ONCHAIN, + "lbtc" to SamRockPaymentMethod.LIQUID, + "liquid-chain" to SamRockPaymentMethod.LIQUID_ONCHAIN, + "btcln" to SamRockPaymentMethod.BTC_LIGHTNING, + "btc-ln" to SamRockPaymentMethod.BTC_LIGHTNING, + ) + + cases.forEach { (setupValue, method) -> + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=$setupValue&otp=secret" + ), + setupValue, + ) + + assertEquals(setOf(method), setup.requestedMethods, setupValue) + } + } + + @Test + fun `parse preserves encoded query values in post URL`() { + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=a%2Bb%20c%26d%3De" + ) + ) + + assertEquals("a+b c&d=e", setup.otp) + assertEquals( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=a%2Bb%20c%26d%3De", + setup.postUrl, + ) + } + + @Test + fun `parse rejects public http`() { + assertNull( + SamRockSetupRequest.parse( + "http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + ) + ) + assertTrue( + SamRockSetupRequest.isPublicHttpProtocolUrl( + "http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + ) + ) + } + + @Test + fun `parse allows local and private http`() { + val urls = listOf( + "http://localhost/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://127.0.0.1/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://192.168.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://172.16.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://10.0.2.2/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://[::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://[fc00::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://[fd00::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://[fe80::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://merchant.local/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + ) + + urls.forEach { + assertNotNull(SamRockSetupRequest.parse(it), it) + } + } + + @Test + fun `parse rejects public and invalid http lookalikes`() { + val urls = listOf( + "http://fc.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://fd.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://172.15.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://172.32.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://192.167.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://8.8.8.8/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://10.0.0.999/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + "http://[2001:db8::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + ) + + urls.forEach { + assertNull(SamRockSetupRequest.parse(it), it) + } + } + + @Test + fun `parse rejects non samrock URLs`() { + assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/other/protocol?otp=secret")) + assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock?otp=secret")) + assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol")) + assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol?otp=")) + assertNull( + SamRockSetupRequest.parse( + "https://user:pass@btcpay.example.com/plugins/store/samrock/protocol?otp=secret" + ) + ) + } + + @Test + fun `parse rejects malformed percent escapes`() { + assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/%zz/samrock/protocol?otp=secret")) + assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol?otp=%zz")) + } + + @Test + fun `sanitized description strips query and fragment`() { + assertEquals( + "https://btcpay.example.com/plugins/store/samrock/protocol", + SamRockSetupRequest.sanitizedDescription( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret#frag" + ), + ) + } + + @Test + fun `sanitized QR log value redacts SamRock query values`() { + val result = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + .sanitizedQrLogValue() + + assertEquals("https://btcpay.example.com/plugins/store/samrock/protocol", result) + assertFalse(result.contains("otp")) + assertFalse(result.contains("secret")) + } + + @Test + fun `sanitized QR log value redacts malformed SamRock-looking query values`() { + val result = "https://btcpay.example.com/plugins/%zz/samrock/protocol?setup=btc-chain&otp=secret" + .sanitizedQrLogValue() + + assertEquals("https://btcpay.example.com/plugins/%zz/samrock/protocol", result) + assertFalse(result.contains("otp")) + assertFalse(result.contains("secret")) + } + + @Test + fun `sanitized QR log value redacts ordinary QR values`() { + val result = "bitcoin:bcrt1qexample?amount=1".sanitizedQrLogValue() + + assertTrue(result.startsWith("redacted#")) + assertFalse(result.contains("bcrt1qexample")) + assertFalse(result.contains("amount")) + } + + @Test + fun `sanitized deeplink log value strips sensitive URL parts`() { + assertEquals( + "https://btcpay.example.com/plugins/store/samrock/protocol", + "https://user:pass@btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + .sanitizedDeeplinkLogValue(), + ) + assertEquals( + "https://example.com/path", + "https://user:pass@example.com/path?token=secret#fragment".sanitizedDeeplinkLogValue(), + ) + assertEquals("bitcoin", "bitcoin:bcrt1qexample?amount=1".sanitizedDeeplinkLogValue()) + assertEquals( + "bitkit://pubky-auth/success", + "bitkit://pubky-auth/success?nonce=secret".sanitizedDeeplinkLogValue(), + ) + } + + @Test + fun `sanitized launch key keeps setup links distinct without exposing query`() { + val first = assertNotNull( + SamRockSetupRequest.sanitizedLaunchKey( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + ) + ) + val second = assertNotNull( + SamRockSetupRequest.sanitizedLaunchKey( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=other" + ) + ) + + assertTrue(first.startsWith("https://btcpay.example.com/plugins/store/samrock/protocol#")) + assertFalse(first.contains("otp")) + assertFalse(first.contains("secret")) + assertFalse(first.contains("setup")) + assertFalse(first == second) + } + + @Test + fun `sanitized description drops userinfo and malformed paths`() { + assertEquals( + "https://btcpay.example.com/plugins/store/samrock/protocol", + SamRockSetupRequest.sanitizedDescription( + "https://user:pass@btcpay.example.com/plugins/store/samrock/protocol?otp=secret" + ), + ) + assertEquals( + "https://btcpay.example.com/plugins/%zz/samrock/protocol", + SamRockSetupRequest.sanitizedDescription( + "https://btcpay.example.com/plugins/%zz/samrock/protocol?otp=secret" + ), + ) + assertEquals( + "https://btcpay.example.com/plugins/store/samrock/protocol", + SamRockSetupRequest.sanitizedDescription( + "https://btcpay.example.com/plugins/store/samrock/protocol?otp=%zz" + ), + ) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt new file mode 100644 index 000000000..4fd2cf7e8 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt @@ -0,0 +1,452 @@ +package to.bitkit.repositories + +import android.content.Context +import com.synonym.bitkitcore.AccountType +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.flowOf +import kotlinx.serialization.json.Json +import org.junit.Before +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.SamRockPaymentMethod +import to.bitkit.models.SamRockSetupRequest +import to.bitkit.services.CoreService +import to.bitkit.services.OnchainService +import to.bitkit.test.BaseUnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SamRockRepoTest : BaseUnitTest() { + companion object { + private const val DESCRIPTOR = "wpkh([f23f9fd2/86'/1'/0']tpub.../0/*)" + private const val INVALID_RESPONSE = "Invalid setup response." + private const val MISSING_MNEMONIC = "Bitkit could not read your recovery phrase." + private const val MISSING_RESULT = "The store did not return a Bitcoin setup result." + private const val MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private const val PASSPHRASE = "wallet passphrase" + private const val POST_URL = + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + private const val REJECTED_DESCRIPTOR = "The store rejected the Bitcoin descriptor." + private const val REQUEST_ERROR = "Could not prepare the setup request." + private const val SETUP_FAILED = "Setup failed." + private const val SUCCESS_RESPONSE = """{"Success":true,"Result":{"Results":{"BTC":{"Success":true}}}}""" + private const val UNSUPPORTED_SETUP = "This QR does not request Bitcoin on-chain setup." + } + + private val context = mock() + private val keychain = mock() + private val settingsStore = mock() + private val coreService = mock() + private val onchainService = mock() + private val samRockHttpClient = mock() + private lateinit var sut: SamRockRepo + + @Before + fun setUp() { + whenever(context.getString(R.string.btcpay__invalid_response)).thenReturn(INVALID_RESPONSE) + whenever(context.getString(R.string.btcpay__missing_mnemonic)).thenReturn(MISSING_MNEMONIC) + whenever(context.getString(R.string.btcpay__missing_result)).thenReturn(MISSING_RESULT) + whenever(context.getString(R.string.btcpay__rejected_descriptor)).thenReturn(REJECTED_DESCRIPTOR) + whenever(context.getString(R.string.btcpay__request_error)).thenReturn(REQUEST_ERROR) + whenever(context.getString(R.string.btcpay__setup_failed)).thenReturn(SETUP_FAILED) + whenever(context.getString(R.string.btcpay__unsupported_text)).thenReturn(UNSUPPORTED_SETUP) + whenever(coreService.onchain).thenReturn(onchainService) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedAddressType = "taproot"))) + sut = SamRockRepo( + context = context, + ioDispatcher = testDispatcher, + json = Json, + keychain = keychain, + settingsStore = settingsStore, + coreService = coreService, + samRockHttpClient = samRockHttpClient, + ) + } + + @Test + fun `selected address type maps to descriptor account type`() { + assertEquals(AccountType.LEGACY, "legacy".toSamRockAccountType()) + assertEquals(AccountType.WRAPPED_SEGWIT, "nestedSegwit".toSamRockAccountType()) + assertEquals(AccountType.NATIVE_SEGWIT, "nativeSegwit".toSamRockAccountType()) + assertEquals(AccountType.TAPROOT, "taproot".toSamRockAccountType()) + assertEquals(AccountType.NATIVE_SEGWIT, null.toSamRockAccountType()) + assertEquals(AccountType.NATIVE_SEGWIT, "unknown".toSamRockAccountType()) + } + + @Test + fun `registerBitcoinOnchain derives descriptor and posts form payload`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(MNEMONIC) + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn("") + whenever { + onchainService.deriveOnchainDescriptor( + mnemonicPhrase = MNEMONIC, + network = Env.network, + bip39Passphrase = null, + accountType = AccountType.TAPROOT, + accountIndex = 0u, + ) + }.thenReturn(DESCRIPTOR) + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, SUCCESS_RESPONSE)) + + val result = sut.registerBitcoinOnchain(setupRequest()) + + assertTrue(result.isSuccess) + val payloadCaptor = argumentCaptor() + verify(samRockHttpClient).postDescriptorSetup( + eq(setupRequest().postUrl), + payloadCaptor.capture(), + ) + assertEquals( + """{"Version":"1.0","BTC":{"Descriptor":"$DESCRIPTOR"}}""", + payloadCaptor.firstValue, + ) + } + + @Test + fun `registerBitcoinOnchain forwards non-empty passphrase`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(MNEMONIC) + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(PASSPHRASE) + whenever { + onchainService.deriveOnchainDescriptor( + mnemonicPhrase = MNEMONIC, + network = Env.network, + bip39Passphrase = PASSPHRASE, + accountType = AccountType.TAPROOT, + accountIndex = 0u, + ) + }.thenReturn(DESCRIPTOR) + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, SUCCESS_RESPONSE)) + + val result = sut.registerBitcoinOnchain(setupRequest()) + + assertTrue(result.isSuccess) + verify(onchainService).deriveOnchainDescriptor( + mnemonicPhrase = MNEMONIC, + network = Env.network, + bip39Passphrase = PASSPHRASE, + accountType = AccountType.TAPROOT, + accountIndex = 0u, + ) + } + + @Test + fun `registerBitcoinOnchain rejects unsupported setup before deriving descriptor`() = test { + val error = assertNotNull(sut.registerBitcoinOnchain(unsupportedSetupRequest()).exceptionOrNull()) + + assertEquals(UNSUPPORTED_SETUP, error.message) + verify(onchainService, never()).deriveOnchainDescriptor( + mnemonicPhrase = any(), + network = any(), + bip39Passphrase = any(), + accountType = any(), + accountIndex = any(), + ) + verify(samRockHttpClient, never()).postDescriptorSetup(any(), any()) + } + + @Test + fun `registerBitcoinOnchain fails without mnemonic before posting`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(MISSING_MNEMONIC, error.message) + verify(samRockHttpClient, never()).postDescriptorSetup(any(), any()) + } + + @Test + fun `registerBitcoinOnchain uses non success response message`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.BadRequest, """{"Success":false,"Message":"bad otp"}""")) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals("bad otp", error.message) + } + + @Test + fun `registerBitcoinOnchain rejects missing top-level success`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, """{"Result":{"BTC":{"Success":true}}}""")) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(INVALID_RESPONSE, error.message) + } + + @Test + fun `registerBitcoinOnchain requires BTC result`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, """{"Success":true,"Result":{"Results":{}}}""")) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(MISSING_RESULT, error.message) + } + + @Test + fun `registerBitcoinOnchain propagates rejected BTC result message`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn( + SamRockHttpResponse( + HttpStatusCode.OK, + """{"Success":true,"Result":{"Results":{"BTC":{"Success":false,"Message":"rejected"}}}}""" + ) + ) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals("rejected", error.message) + } + + @Test + fun `registerBitcoinOnchain accepts lowercase envelope and direct result map`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn( + SamRockHttpResponse( + HttpStatusCode.OK, + """{"success":true,"result":{"BTC":{"success":true}}}""" + ) + ) + + val result = sut.registerBitcoinOnchain(setupRequest()) + + assertTrue(result.isSuccess) + } + + @Test + fun `registerBitcoinOnchain uses lowercase non success response message`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.BadRequest, """{"success":false,"message":"bad otp"}""")) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals("bad otp", error.message) + } + + @Test + fun `registerBitcoinOnchain uses setup failed fallback`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, """{"Success":false}""")) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(SETUP_FAILED, error.message) + } + + @Test + fun `registerBitcoinOnchain uses rejected descriptor fallback`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn( + SamRockHttpResponse( + HttpStatusCode.OK, + """{"Success":true,"Result":{"BTC":{"Success":false}}}""" + ) + ) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(REJECTED_DESCRIPTOR, error.message) + } + + @Test + fun `registerBitcoinOnchain rejects nested non boolean BTC success`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn( + SamRockHttpResponse( + HttpStatusCode.OK, + """{"Success":true,"Result":{"BTC":{"Success":"true"}}}""" + ) + ) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(MISSING_RESULT, error.message) + } + + @Test + fun `registerBitcoinOnchain rejects malformed response bodies`() = test { + val cases = listOf( + "" to INVALID_RESPONSE, + """{"Success":true}""" to MISSING_RESULT, + """{"Success":true,"Result":"bad"}""" to MISSING_RESULT, + """{"Success":true,"Result":{"Results":{"BTC":{}}}}""" to MISSING_RESULT, + """{"Success":"true","Result":{"Results":{"BTC":{"Success":true}}}}""" to INVALID_RESPONSE, + """{"Success":{},"Result":{"Results":{"BTC":{"Success":true}}}}""" to INVALID_RESPONSE, + """{"Success":true,"Result":{"Results":{"BTC":{"Success":[]}}}}""" to MISSING_RESULT, + """{"Success":true,"Result":{"Results":{"btc":{"Success":true}}}}""" to MISSING_RESULT, + ) + + cases.forEach { (body, expectedMessage) -> + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, body)) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull(), body) + + assertEquals(expectedMessage, error.message, body) + } + } + + @Test + fun `registerBitcoinOnchain uses status description for non success malformed body`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenReturn(SamRockHttpResponse(HttpStatusCode.InternalServerError, "")) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(HttpStatusCode.InternalServerError.description, error.message) + } + + @Test + fun `registerBitcoinOnchain wraps transport errors without leaking URL`() = test { + stubDescriptor() + whenever(samRockHttpClient.postDescriptorSetup(any(), any())) + .thenThrow(IllegalStateException("Failed request to ${setupRequest().postUrl}")) + + val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull()) + + assertEquals(REQUEST_ERROR, error.message) + assertFalse(error.message.orEmpty().contains("secret")) + } + + @Test + fun `samRockFormBody uses json form field`() { + val body = samRockFormBody("""{"BTC":{"Descriptor":"descriptor"}}""") + + assertEquals("""{"BTC":{"Descriptor":"descriptor"}}""", body.formData["json"]) + assertEquals("application/x-www-form-urlencoded; charset=UTF-8", body.contentType.toString()) + } + + @Test + fun `response parser accepts uppercase envelope`() { + val envelope = assertNotNull( + SamRockResponseParser.decode( + """ + { + "Success": true, + "Message": "ok", + "Result": { + "Results": { + "BTC": { "Success": true, "Message": "registered" } + } + } + } + """.trimIndent() + ) + ) + + assertTrue(envelope.success == true) + assertEquals("ok", envelope.message) + assertTrue(envelope.result?.results?.get("BTC")?.success == true) + assertEquals("registered", envelope.result?.results?.get("BTC")?.message) + } + + @Test + fun `response parser accepts lowercase envelope and direct result map`() { + val envelope = assertNotNull( + SamRockResponseParser.decode( + """ + { + "success": true, + "result": { + "BTC": { "success": true } + } + } + """.trimIndent() + ) + ) + + assertTrue(envelope.success == true) + assertTrue(envelope.result?.results?.get("BTC")?.success == true) + } + + @Test + fun `response parser preserves failed method message`() { + val envelope = assertNotNull( + SamRockResponseParser.decode( + """ + { + "success": true, + "result": { + "results": { + "BTC": { "success": false, "message": "descriptor rejected" } + } + } + } + """.trimIndent() + ) + ) + val btcResult = assertNotNull(envelope.result?.results?.get("BTC")) + + assertFalse(btcResult.success) + assertEquals("descriptor rejected", btcResult.message) + } + + @Test + fun `response parser rejects invalid or non-object json`() { + assertNull(SamRockResponseParser.decode("")) + assertNull(SamRockResponseParser.decode("[]")) + } + + private suspend fun stubDescriptor() { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(MNEMONIC) + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) + whenever { + onchainService.deriveOnchainDescriptor( + mnemonicPhrase = MNEMONIC, + network = Env.network, + bip39Passphrase = null, + accountType = AccountType.TAPROOT, + accountIndex = 0u, + ) + }.thenReturn(DESCRIPTOR) + } + + private fun setupRequest() = SamRockSetupRequest( + postUrl = POST_URL, + storeId = "store", + otp = "secret", + requestedMethods = setOf(SamRockPaymentMethod.BTC_ONCHAIN), + hasUnknownMethods = false, + hostDisplayName = "btcpay.example.com", + logDescription = "https://btcpay.example.com/plugins/store/samrock/protocol", + ) + + private fun unsupportedSetupRequest() = SamRockSetupRequest( + postUrl = POST_URL, + storeId = "store", + otp = "secret", + requestedMethods = setOf(SamRockPaymentMethod.BTC_LIGHTNING), + hasUnknownMethods = false, + hostDisplayName = "btcpay.example.com", + logDescription = "https://btcpay.example.com/plugins/store/samrock/protocol", + ) +} diff --git a/app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt b/app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt new file mode 100644 index 000000000..aebb4f014 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt @@ -0,0 +1,58 @@ +package to.bitkit.ui + +import android.content.Intent +import android.net.Uri +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MainActivityLaunchKeyTest { + private companion object { + private const val SAMROCK_SETUP_URL = + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + } + + @Test + fun `launch key redacts SamRock query values`() { + val key = assertNotNull(viewIntent(SAMROCK_SETUP_URL).launchKey()) + + assertTrue(key.startsWith("https://btcpay.example.com/plugins/store/samrock/protocol#")) + assertFalse(key.contains("otp")) + assertFalse(key.contains("secret")) + assertFalse(key.contains("setup")) + } + + @Test + fun `launch key keeps SamRock setup links with different OTPs distinct`() { + val first = assertNotNull(viewIntent(SAMROCK_SETUP_URL).launchKey()) + val second = assertNotNull( + viewIntent("https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=other") + .launchKey() + ) + + assertNotEquals(first, second) + } + + @Test + fun `launch key ignores non view intents`() { + val intent = mock { + on { action }.thenReturn(Intent.ACTION_SEND) + } + + assertNull(intent.launchKey()) + } + + private fun viewIntent(url: String): Intent { + val uri = mock { + on { toString() }.thenReturn(url) + } + return mock { + on { action }.thenReturn(Intent.ACTION_VIEW) + on { data }.thenReturn(uri) + } + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e0f099398..aeac2d210 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1,7 +1,10 @@ package to.bitkit.viewmodels +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.net.toUri import app.cash.turbine.test import com.synonym.bitkitcore.LightningInvoice @@ -20,6 +23,7 @@ import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -35,6 +39,8 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState import to.bitkit.models.PubkyProfile +import to.bitkit.models.SamRockPaymentMethod +import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo @@ -53,6 +59,7 @@ import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -105,8 +112,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyRepo = mock() private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() + private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() + private val clipboardManager = mock() + private val toastManager = mock() private val balanceState = MutableStateFlow(BalanceState()) private val settingsData = MutableStateFlow(SettingsData()) @@ -128,6 +138,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun stubRepositories() { whenever(context.getString(any())).thenReturn("") + whenever(context.getSystemService(Context.CLIPBOARD_SERVICE)).thenReturn(clipboardManager) whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) @@ -182,6 +193,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) } .thenReturn(Result.success(2u)) whenever(lightningRepo.canSend(any())).thenReturn(true) + whenever(toastManager.currentToast).thenReturn(MutableStateFlow(null)) } private fun stubSettingsStore() { @@ -197,7 +209,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun createViewModel() = AppViewModel( connectivityRepo = connectivityRepo, healthRepo = healthRepo, - toastManagerProvider = { mock() }, + toastManagerProvider = { toastManager }, timedSheetManagerProvider = { timedSheetManager }, context = context, bgDispatcher = testDispatcher, @@ -219,6 +231,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { coreService = coreService, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, + samRockRepo = samRockRepo, appUpdateSheet = mock(), backupSheet = mock(), notificationsSheet = mock(), @@ -245,6 +258,236 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.canSwitchWallet) } + @Test + fun `scan SamRock setup opens BTCPay connection sheet without core decode`() = test { + sut.onScanResult( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + ) + advanceUntilIdle() + + val sheet = sut.currentSheet.value + assertTrue(sheet is Sheet.BTCPayConnection) + assertEquals("secret", sheet.setup.otp) + verify(coreService, never()).decode(any()) + } + + @Test + fun `scan lightning-only SamRock setup shows unsupported toast without sheet`() = test { + sut.onScanResult( + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-ln&otp=secret" + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue( + check { + assertEquals("BTCPayUnsupportedToast", it.testTag) + } + ) + verify(coreService, never()).decode(any()) + } + + @Test + fun `SamRock deeplink opens BTCPay connection sheet without core decode`() = test { + sut.handleDeeplinkIntent(samRockIntent(SAMROCK_SETUP_URL)) + advanceUntilIdle() + + val sheet = sut.currentSheet.value + assertTrue(sheet is Sheet.BTCPayConnection) + assertEquals("secret", sheet.setup.otp) + verify(coreService, never()).decode(any()) + } + + @Test + fun `SamRock deeplink containing recovery mode text opens BTCPay sheet`() = test { + sut.handleDeeplinkIntent( + samRockIntent("https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=recovery-mode") + ) + advanceUntilIdle() + + val sheet = sut.currentSheet.value + assertTrue(sheet is Sheet.BTCPayConnection) + assertEquals("recovery-mode", sheet.setup.otp) + verify(lightningRepo, never()).setRecoveryMode(true) + verify(coreService, never()).decode(any()) + } + + @Test + fun `public http SamRock deeplink shows setup error without core decode`() = test { + sut.handleDeeplinkIntent( + samRockIntent("http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret") + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue( + check { + assertEquals("BTCPayInvalidSetupToast", it.testTag) + } + ) + verify(coreService, never()).decode(any()) + } + + @Test + fun `unsupported SamRock deeplink shows unsupported toast without sheet`() = test { + sut.handleDeeplinkIntent( + samRockIntent("https://btcpay.example.com/plugins/store/samrock/protocol?setup=btcln&otp=secret") + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue( + check { + assertEquals("BTCPayUnsupportedToast", it.testTag) + } + ) + verify(coreService, never()).decode(any()) + } + + @Test + fun `SamRock deeplink is ignored when wallet does not exist`() = test { + whenever(walletRepo.walletExists()).thenReturn(false) + + sut.handleDeeplinkIntent(samRockIntent(SAMROCK_SETUP_URL)) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(coreService, never()).decode(any()) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `recovery mode deeplink enables recovery mode`() = test { + sut.handleDeeplinkIntent(recoveryModeIntent()) + advanceUntilIdle() + + verify(lightningRepo).setRecoveryMode(true) + } + + @Test + fun `connectBTCPay hides sheet and shows success toast`() = test { + val setup = samRockSetupRequest() + whenever(samRockRepo.registerBitcoinOnchain(setup)).thenReturn(Result.success(Unit)) + sut.showSheet(Sheet.BTCPayConnection(setup)) + advanceUntilIdle() + + val result = sut.connectBTCPay(setup) + advanceUntilIdle() + + assertTrue(result.isSuccess) + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue( + check { + assertEquals("BTCPayConnectedToast", it.testTag) + } + ) + } + + @Test + fun `connectBTCPay failure keeps sheet and shows error toast`() = test { + val setup = samRockSetupRequest() + whenever(samRockRepo.registerBitcoinOnchain(setup)).thenReturn(Result.failure(AppError("failed"))) + sut.showSheet(Sheet.BTCPayConnection(setup)) + advanceUntilIdle() + + val result = sut.connectBTCPay(setup) + advanceUntilIdle() + + assertTrue(result.isFailure) + assertTrue(sut.currentSheet.value is Sheet.BTCPayConnection) + verify(toastManager).enqueue( + check { + assertEquals("BTCPayConnectionErrorToast", it.testTag) + } + ) + } + + @Test + fun `canDecodeClipboard accepts SamRock setup without core decode`() = test { + val setupUrl = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + clearInvocations(coreService) + + assertTrue(sut.canDecodeClipboard(setupUrl)) + verify(coreService, never()).decode(any()) + } + + @Test + fun `canDecodeClipboard accepts invalid SamRock setup without core decode`() = test { + val setupUrl = "http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" + clearInvocations(coreService) + + assertTrue(sut.canDecodeClipboard(setupUrl)) + verify(coreService, never()).decode(any()) + } + + @Test + fun `paste SamRock setup opens BTCPay connection sheet without core decode`() = test { + val clipData = mock() + val item = mock() + whenever(item.text).thenReturn(SAMROCK_SETUP_URL) + whenever(clipData.getItemAt(0)).thenReturn(item) + whenever(clipboardManager.primaryClip).thenReturn(clipData) + clearInvocations(coreService) + + sut.setSendEvent(SendEvent.Paste) + advanceUntilIdle() + + val sheet = sut.currentSheet.value + assertTrue(sheet is Sheet.BTCPayConnection) + assertEquals("secret", sheet.setup.otp) + verify(coreService, never()).decode(any()) + } + + @Test + fun `paste empty clipboard shows warning without core decode`() = test { + whenever(clipboardManager.primaryClip).thenReturn(null) + clearInvocations(coreService) + + sut.setSendEvent(SendEvent.Paste) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue(any()) + verify(coreService, never()).decode(any()) + } + + @Test + fun `scanner sheet SamRock result opens BTCPay connection sheet without core decode`() = test { + clearInvocations(coreService) + + sut.showScannerSheet() + advanceUntilIdle() + assertTrue(sut.currentSheet.value is Sheet.QrScanner) + + sut.onScannerSheetResult(SAMROCK_SETUP_URL) + advanceUntilIdle() + + val sheet = sut.currentSheet.value + assertTrue(sheet is Sheet.BTCPayConnection) + assertEquals("secret", sheet.setup.otp) + verify(coreService, never()).decode(any()) + } + + @Test + fun `scanner sheet handler is bypassed for SamRock result`() = test { + var handled = false + clearInvocations(coreService) + + sut.showScannerSheet { + handled = true + } + advanceUntilIdle() + assertTrue(sut.currentSheet.value is Sheet.QrScanner) + + sut.onScannerSheetResult(SAMROCK_SETUP_URL) + advanceUntilIdle() + + val sheet = sut.currentSheet.value + assertTrue(sheet is Sheet.BTCPayConnection) + assertFalse(handled) + verify(coreService, never()).decode(any()) + } + @Test fun `canSwitchWallet is false when amount equals dust limit`() = test { balanceState.value = BalanceState( @@ -992,6 +1235,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true } + private fun samRockSetupRequest() = SamRockSetupRequest( + postUrl = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", + storeId = "store", + otp = "secret", + requestedMethods = setOf(SamRockPaymentMethod.BTC_ONCHAIN), + hasUnknownMethods = false, + hostDisplayName = "btcpay.example.com", + logDescription = "https://btcpay.example.com/plugins/store/samrock/protocol", + ) + + private fun samRockIntent(url: String): Intent { + val uri = mock { + on { toString() }.thenReturn(url) + on { scheme }.thenReturn("https") + on { host }.thenReturn("btcpay.example.com") + on { path }.thenReturn("/plugins/store/samrock/protocol") + } + return mock { + on { action }.thenReturn(Intent.ACTION_VIEW) + on { data }.thenReturn(uri) + } + } + + private fun recoveryModeIntent(): Intent { + val uri = mock { + on { toString() }.thenReturn("bitkit://recovery-mode") + on { scheme }.thenReturn("bitkit") + on { host }.thenReturn("recovery-mode") + on { pathSegments }.thenReturn(emptyList()) + } + return mock { + on { action }.thenReturn(Intent.ACTION_VIEW) + on { data }.thenReturn(uri) + } + } + @Suppress("UNCHECKED_CAST") private fun setPendingContactPaymentContext(paymentHash: String, publicKey: String) { val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts") @@ -1051,3 +1330,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { method.invoke(sut) } } + +private const val SAMROCK_SETUP_URL = + "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" diff --git a/changelog.d/next/953.added.md b/changelog.d/next/953.added.md new file mode 100644 index 000000000..f6b98fdbb --- /dev/null +++ b/changelog.d/next/953.added.md @@ -0,0 +1 @@ +Added BTCPay wallet connection support for sharing Bitcoin receive descriptors. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e2c02fda..e4644f9ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.58" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.60" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } From 13f0ba2ac9a7054f17c6b704bf9a7dbb190b19fe Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 21 May 2026 13:00:33 -0500 Subject: [PATCH 2/4] fix: align btcpay sheet design --- .../bitkit/ui/sheets/BTCPayConnectionSheet.kt | 105 ++++++++++++------ .../next/{953.added.md => 961.added.md} | 0 2 files changed, 69 insertions(+), 36 deletions(-) rename changelog.d/next/{953.added.md => 961.added.md} (100%) diff --git a/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt index 8e30bc274..c6cf89064 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -20,7 +21,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -35,11 +35,13 @@ import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight @@ -84,36 +86,37 @@ private fun Content( VerticalSpacer(16.dp) Column( - verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.weight(1f) ) { - ConnectionRow( - iconRes = R.drawable.ic_store_front, - title = stringResource(R.string.btcpay__store_label), - subtitle = setup.hostDisplayName, - ) + StoreHeader(setup = setup) + VerticalSpacer(24.dp) BodyM( text = stringResource(R.string.btcpay__sheet_description), color = Colors.White64, ) + VerticalSpacer(20.dp) - ConnectionRow( - iconRes = R.drawable.ic_btc_circle, - title = stringResource(R.string.btcpay__onchain_label), - subtitle = stringResource(R.string.btcpay__descriptor_label), - ) - - if (setup.requestsUnsupportedMethods) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { ConnectionRow( - iconRes = R.drawable.ic_warning, - title = stringResource(R.string.btcpay__limited_support_label), - subtitle = stringResource(R.string.btcpay__unsupported_note), - iconBackground = Colors.Yellow16, + iconRes = R.drawable.ic_btc_circle, + title = stringResource(R.string.btcpay__onchain_label), + subtitle = stringResource(R.string.btcpay__descriptor_label), ) + + if (setup.requestsUnsupportedMethods) { + ConnectionRow( + iconRes = R.drawable.ic_warning, + title = stringResource(R.string.btcpay__limited_support_label), + subtitle = stringResource(R.string.btcpay__unsupported_note), + ) + } } errorText?.let { + VerticalSpacer(16.dp) BodyS( text = it, color = Colors.Red, @@ -157,34 +160,66 @@ private fun Content( } @Composable -private fun ConnectionRow( - iconRes: Int, - title: String, - subtitle: String, +private fun StoreHeader( + setup: SamRockSetupRequest, modifier: Modifier = Modifier, - iconBackground: Color = Colors.White10, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(Colors.White08) - .padding(16.dp) + modifier = modifier.fillMaxWidth() ) { Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(20.dp)) - .background(iconBackground) + .size(84.dp) + .clip(CircleShape) + .background(Colors.White08) ) { Image( - painter = painterResource(iconRes), + painter = painterResource(R.drawable.ic_store_front), contentDescription = null, - modifier = Modifier.size(22.dp) + modifier = Modifier.size(56.dp) ) } + HorizontalSpacer(16.dp) + Column( + modifier = Modifier.weight(1f) + ) { + Caption( + text = stringResource(R.string.btcpay__store_label), + color = Colors.White64, + ) + VerticalSpacer(6.dp) + Title( + text = setup.hostDisplayName, + color = Colors.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ConnectionRow( + iconRes: Int, + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.Top, + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.White08) + .padding(16.dp) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) HorizontalSpacer(12.dp) Column( modifier = Modifier.weight(1f) @@ -195,12 +230,10 @@ private fun ConnectionRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - VerticalSpacer(2.dp) + VerticalSpacer(4.dp) BodyS( text = subtitle, color = Colors.White64, - maxLines = 2, - overflow = TextOverflow.Ellipsis, ) } } diff --git a/changelog.d/next/953.added.md b/changelog.d/next/961.added.md similarity index 100% rename from changelog.d/next/953.added.md rename to changelog.d/next/961.added.md From 7feeff2a3d4b9afd98e11bdca8fc7fbdd6da88c4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 21 May 2026 13:17:38 -0500 Subject: [PATCH 3/4] fix: align btcpay payload shape --- app/src/main/java/to/bitkit/repositories/SamRockRepo.kt | 6 +----- app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt b/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt index 2291cbe83..087f7708e 100644 --- a/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt @@ -53,7 +53,6 @@ class SamRockRepo @Inject constructor( ) { companion object { private const val BITCOIN_METHOD = "BTC" - private const val SAMROCK_VERSION = "1.0" } suspend fun registerBitcoinOnchain(setup: SamRockSetupRequest): Result = withContext(ioDispatcher) { @@ -65,8 +64,7 @@ class SamRockRepo @Inject constructor( val descriptor = derivePrimaryAddressDescriptor() val payload = json.encodeToString( SamRockDescriptorPayload( - version = SAMROCK_VERSION, - btc = SamRockBitcoinDescriptor(descriptor = descriptor) + btc = SamRockBitcoinDescriptor(descriptor = descriptor), ) ) @@ -240,8 +238,6 @@ internal fun String?.toSamRockAccountType(): AccountType { @Serializable private data class SamRockDescriptorPayload( - @SerialName("Version") - val version: String, @SerialName("BTC") val btc: SamRockBitcoinDescriptor, ) diff --git a/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt index 4fd2cf7e8..34f61cb6a 100644 --- a/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt @@ -113,7 +113,7 @@ class SamRockRepoTest : BaseUnitTest() { payloadCaptor.capture(), ) assertEquals( - """{"Version":"1.0","BTC":{"Descriptor":"$DESCRIPTOR"}}""", + """{"BTC":{"Descriptor":"$DESCRIPTOR"}}""", payloadCaptor.firstValue, ) } From 8809853283bf4ae916c8f222fdede2f7802839f0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 21 May 2026 14:45:16 -0500 Subject: [PATCH 4/4] fix: accept btcpay setup base paths --- .../to/bitkit/models/SamRockSetupRequest.kt | 26 ++++++++++-------- .../bitkit/models/SamRockSetupRequestTest.kt | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt b/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt index 8a46aedff..28f097d66 100644 --- a/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt +++ b/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt @@ -29,11 +29,7 @@ data class SamRockSetupRequest( val pathComponents = uri.decodedPathComponents() ?: return null - - if (pathComponents.size != EXPECTED_PATH_COMPONENTS) return null - if (pathComponents[0] != PLUGINS_PATH_COMPONENT) return null - if (!pathComponents[2].equals(SAMROCK_PATH_COMPONENT, ignoreCase = true)) return null - if (!pathComponents[3].equals(PROTOCOL_PATH_COMPONENT, ignoreCase = true)) return null + val storeId = pathComponents.samRockStoreId() ?: return null val queryItems = runCatching { parseQuery(uri.rawQuery ?: return null) }.getOrNull() ?: return null val otp = queryItems.firstValue(OTP_QUERY_KEY)?.takeIf { it.isNotBlank() } ?: return null @@ -42,7 +38,7 @@ data class SamRockSetupRequest( return SamRockSetupRequest( postUrl = buildPostUrl(uri, setup, otp), - storeId = pathComponents[1], + storeId = storeId, otp = otp, requestedMethods = parsedMethods.methods, hasUnknownMethods = parsedMethods.hasUnknownMethods, @@ -145,10 +141,18 @@ data class SamRockSetupRequest( val pathComponents = decodedPathComponents() ?: return false - return pathComponents.size == EXPECTED_PATH_COMPONENTS && - pathComponents[0] == PLUGINS_PATH_COMPONENT && - pathComponents[2].equals(SAMROCK_PATH_COMPONENT, ignoreCase = true) && - pathComponents[3].equals(PROTOCOL_PATH_COMPONENT, ignoreCase = true) + return pathComponents.samRockStoreId() != null + } + + private fun List.samRockStoreId(): String? { + if (size < SAMROCK_PROTOCOL_COMPONENT_COUNT) return null + + val pluginsIndex = size - SAMROCK_PROTOCOL_COMPONENT_COUNT + if (this[pluginsIndex] != PLUGINS_PATH_COMPONENT) return null + if (!this[pluginsIndex + 2].equals(SAMROCK_PATH_COMPONENT, ignoreCase = true)) return null + if (!this[pluginsIndex + 3].equals(PROTOCOL_PATH_COMPONENT, ignoreCase = true)) return null + + return this[pluginsIndex + 1] } private fun URI.hasAuthority(): Boolean { @@ -265,7 +269,7 @@ data class SamRockSetupRequest( private const val PATH_SEPARATOR = '/' private const val SCHEME_SEPARATOR = "://" private const val USER_INFO_SEPARATOR = '@' - private const val EXPECTED_PATH_COMPONENTS = 4 + private const val SAMROCK_PROTOCOL_COMPONENT_COUNT = 4 private const val PLUGINS_PATH_COMPONENT = "plugins" private const val SAMROCK_PATH_COMPONENT = "samrock" private const val PROTOCOL_PATH_COMPONENT = "protocol" diff --git a/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt b/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt index 142913036..9680e0bcc 100644 --- a/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt @@ -27,6 +27,28 @@ class SamRockSetupRequestTest { assertTrue(setup.requestsUnsupportedMethods) } + @Test + fun `parse accepts setup URL under base path`() { + val setup = assertNotNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret" + ) + ) + + assertEquals("store-1", setup.storeId) + assertEquals("btcpay.example.com", setup.hostDisplayName) + assertEquals( + "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret", + setup.postUrl, + ) + assertEquals( + "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol", + SamRockSetupRequest.sanitizedDescription( + "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret" + ), + ) + } + @Test fun `parse defaults missing setup to all`() { val setup = assertNotNull( @@ -182,6 +204,11 @@ class SamRockSetupRequestTest { assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock?otp=secret")) assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol")) assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol?otp=")) + assertNull( + SamRockSetupRequest.parse( + "https://btcpay.example.com/plugins/store/samrock/protocol/extra?otp=secret" + ) + ) assertNull( SamRockSetupRequest.parse( "https://user:pass@btcpay.example.com/plugins/store/samrock/protocol?otp=secret"