diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 955d5e477d..015d966b04 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -297,6 +297,7 @@ android { unitTests { isReturnDefaultValues = true // mockito isIncludeAndroidResources = true // robolectric + all { it.maxHeapSize = "2g" } } } lint { @@ -320,6 +321,23 @@ androidComponents { val buildConfigFields = requireNotNull(variant.buildConfigFields) { "buildFeatures.buildConfig must stay enabled for '${variant.name}'." } + buildConfigFields.put( + "USDT_BUNDLER_URL", + localProp("USDT_BUNDLER_URL").orElse("https://bitkit.to/v1/usdt/rpc").stringField(), + ) + buildConfigFields.put( + "USDT_BRIDGES_URL", + localProp("USDT_BRIDGES_URL").orElse("https://bitkit.to/v1/usdt/bridges").stringField(), + ) + buildConfigFields.put( + "USDT_DEPOSITS_URL", + localProp("USDT_DEPOSITS_URL").orElse("https://bitkit.to/v1/usdt/deposits").stringField(), + ) + buildConfigFields.put("USDT_BRIDGE_NETWORKS", localProp("USDT_BRIDGE_NETWORKS").orElse("").stringField()) + buildConfigFields.put( + "USDT_RPC_URL", + localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(), + ) buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) @@ -516,10 +534,17 @@ dependencies { // Crypto implementation(libs.bouncycastle.provider.jdk) implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") } - implementation(libs.bitkit.core) + val localBitkitCoreAar = providers.gradleProperty("bitkitCoreAar").orNull + if (localBitkitCoreAar != null) { + debugImplementation(files(localBitkitCoreAar)) + debugImplementation("org.jetbrains.kotlinx:atomicfu:0.23.1") + releaseImplementation(libs.bitkit.core) + } else { + implementation(libs.bitkit.core) + } implementation(libs.paykit) implementation(libs.vss.client) - nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) + if (localBitkitCoreAar == null) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.paykit.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.vss.client.nativeDebugSymbolsArtifact()) diff --git a/app/src/androidTest/java/to/bitkit/ui/components/ButtonTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/ButtonTest.kt index df343e05d3..5c499dc199 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/ButtonTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/ButtonTest.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -50,7 +51,7 @@ class ButtonTest { onClick = {}, size = ButtonSize.Small, fullWidth = false, - modifier = Modifier.testTag("SecondarySmallEnabled") + testTag = "SecondarySmallEnabled", ) SecondaryButton( text = "Secondary", @@ -94,6 +95,7 @@ class ButtonTest { ).forEach { composeTestRule.onNodeWithTag(it).assertHeightIsEqualTo(40.dp) } + composeTestRule.onNodeWithTag("SecondarySmallEnabled").assertHasClickAction() composeTestRule.onNodeWithTag("PrimaryLarge").assertHeightIsEqualTo(56.dp) composeTestRule.onNodeWithTag("SecondaryLarge").assertHeightIsEqualTo(56.dp) } diff --git a/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt new file mode 100644 index 0000000000..2db0fa8bd3 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt @@ -0,0 +1,98 @@ +package to.bitkit.ui.components + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.ext.getClipboardText +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.screens.wallets.usdt.UsdtReceiveContent +import to.bitkit.ui.sheets.NewTransactionSheetView +import to.bitkit.ui.theme.AppThemeSurface +import java.util.Locale +import kotlin.test.assertEquals + +@HiltAndroidTest +@ComposeUi +class UsdtReceivePrivacyTest { + private val originalLocale = Locale.getDefault() + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Before + fun setup() { + Locale.setDefault(Locale.US) + hiltRule.inject() + } + + @After + fun teardown() { + Locale.setDefault(originalLocale) + } + + @Test + fun qrCopyPreservesPaymentUriAndDetailsCopyOnlyAddress() { + val address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" + val uri = "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=$address&uint256=3500000" + lateinit var context: Context + composeTestRule.setContent { + context = LocalContext.current + AppThemeSurface { + Column { + UsdtReceiveContent(address, uri, null, "Arbitrum One", {}, {}) + } + } + } + composeTestRule.onNodeWithTag("ReceiveCopyQR").performClick() + composeTestRule.runOnIdle { assertEquals(uri, context.getClipboardText()) } + composeTestRule.onNodeWithTag("UsdtReceiveDetails").performClick() + composeTestRule.onNodeWithText("Copy").performClick() + composeTestRule.runOnIdle { assertEquals(address, context.getClipboardText()) } + composeTestRule.runOnIdle { composeTestRule.activity.onBackPressedDispatcher.onBackPressed() } + composeTestRule.onNodeWithTag("ReceiveCopyQR").assertIsDisplayed() + } + + @Test + fun incomingPaymentHonorsHiddenBalance() { + composeTestRule.mainClock.autoAdvance = false + val hidden = mutableStateOf(true) + composeTestRule.setContent { + AppThemeSurface { + NewTransactionSheetView( + details = NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = 123_456_789uL, + ), + onCloseClick = {}, + onDetailClick = {}, + hideBalance = hidden.value, + ) + } + } + composeTestRule.mainClock.advanceTimeBy(1000) + composeTestRule.onNodeWithText("123.46").assertDoesNotExist() + composeTestRule.runOnIdle { hidden.value = false } + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.onNodeWithText("123.46").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt index 0de2ab6025..992bd3fba0 100644 --- a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -61,8 +61,8 @@ class MnemonicInputFieldTest { onPositionChange = {}, onBackspaceInEmpty = { backspaceInEmptyCount++ }, focusRequester = focusRequester, - index = 0, isFocused = true, + index = 0, ) } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 4e4f3cac9d..e21a51899b 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -9,22 +9,30 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile import to.bitkit.models.USD_SYMBOL import to.bitkit.repositories.AmountInputHandler import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus @@ -35,9 +43,9 @@ import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.AmountInputViewModel +import kotlin.test.assertEquals import kotlin.time.ExperimentalTime import kotlin.time.Instant -import kotlin.test.assertEquals @ComposeUi class CreatePaymentRequestScreenTest { @@ -65,6 +73,41 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertDoesNotExist() } + @Test + fun acceptedAssetsGroupsBitcoinRailsAndRequiresASelection() { + var submitted: PaykitPaymentRequestDraft? = null + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestDetailsContent( + initialDraft = draft, + availableMethods = persistentListOf( + MethodId.P2wpkh.rawValue, + MethodId.Bolt11.rawValue, + MethodId.UsdtArbitrum.rawValue + ), + contact = PubkyProfile.placeholder(target.publicKey), + isCreating = false, + onBack = {}, + onEditAmount = {}, + onSend = { submitted = it }, + ) + } + } + composeTestRule.onNodeWithTag("PaymentRequestAccept-bitcoin") + .performScrollTo().assertIsSelected().performClick() + composeTestRule.onNodeWithTag("PaymentRequestAccept-usdt").assertIsSelected() + composeTestRule.onNodeWithTag("PaymentRequestSend").performClick() + assertEquals(listOf(MethodId.UsdtArbitrum.rawValue), submitted?.acceptedPaymentEndpointIdentifiers) + composeTestRule.onNodeWithTag("PaymentRequestAccept-usdt").performClick().assertIsNotSelected() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsNotEnabled() + composeTestRule.onNodeWithTag("PaymentRequestAccept-bitcoin").performClick().assertIsSelected() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsEnabled().performClick() + assertEquals( + setOf(MethodId.P2wpkh.rawValue, MethodId.Bolt11.rawValue), + submitted?.acceptedPaymentEndpointIdentifiers?.toSet() + ) + } + @Test fun amountShowsNumberPadAndContinue() { var currencies by mutableStateOf(CurrencyState()) @@ -99,7 +142,11 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestAmountUnit").performClick() - composeTestRule.onAllNodesWithText(BITCOIN_SYMBOL, substring = true, useUnmergedTree = true).assertCountEquals(1) + composeTestRule.onAllNodesWithText( + BITCOIN_SYMBOL, + substring = true, + useUnmergedTree = true + ).assertCountEquals(1) } @Test @@ -214,7 +261,7 @@ class CreatePaymentRequestScreenTest { } private val draft = PaykitPaymentRequestDraft( - amountSats = 25_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 25_000uL), note = "Dinner", expiresAt = Instant.parse("2027-01-15T09:00:00Z"), ) @@ -226,8 +273,8 @@ class CreatePaymentRequestScreenTest { private val request = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = target.publicKey, - amountValue = "0.00025", - amountSats = draft.amountSats, + amount = draft.amount, + paymentReference = "reference", note = draft.note, createdAt = Instant.parse("2027-01-15T08:00:00Z"), expiresAt = Instant.DISTANT_FUTURE, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 4c7808fe97..2056294f6a 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -19,6 +19,9 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import org.junit.Rule import org.junit.Test +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile import to.bitkit.repositories.CurrencyState @@ -316,8 +319,8 @@ class PaymentRequestsScreenTest { private fun request(id: String = "request") = PaykitPaymentRequest( paymentRequestId = id, counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - amountValue = "0.00025", - amountSats = 25_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 25_000uL), + paymentReference = "reference-$id", note = "Dinner", createdAt = Instant.parse("2027-01-15T08:00:00Z"), expiresAt = null, @@ -327,8 +330,9 @@ class PaymentRequestsScreenTest { private fun subscription(note: String) = PaykitSubscription( paymentRequestId = "subscription", counterparty = request().counterparty, - amountValue = "0.00025", - amountSats = 25_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 25_000uL), + paymentReference = "invoice", + pricing = PaykitRequestPricing(), note = note, createdAt = Instant.parse("2027-01-15T08:00:00Z"), proposalExpiresAt = null, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt index cf2aa3c9d5..d20e3d5fef 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt @@ -27,7 +27,11 @@ import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitRecurrenceUnit @@ -55,16 +59,20 @@ class CreateSubscriptionScreenTest { var frequency by mutableStateOf(PaykitRecurrenceUnit.Month) var isLoadingIcon by mutableStateOf(false) var choseRecipient = false + var acceptedMethods by mutableStateOf(persistentListOf(MethodId.P2wpkh.rawValue)) composeTestRule.setContent { AppThemeSurface { CreateSubscriptionDetails( - amountSats = amount, + amount = PaykitAmount(PaykitAsset.BTC, amount), + availableMethods = persistentListOf(MethodId.P2wpkh.rawValue, MethodId.UsdtArbitrum.rawValue), + acceptedMethods = acceptedMethods, name = name, description = "Monthly support", frequency = frequency, selectedIconUri = null, isLoadingIcon = isLoadingIcon, onAmountClick = {}, + onClickAsset = {}, onNameChange = { name = it }, onDescriptionChange = {}, onFrequencyChange = { frequency = it }, @@ -79,11 +87,19 @@ class CreateSubscriptionScreenTest { composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() composeTestRule.runOnIdle { amount = 1000uL } composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsEnabled() - listOf(PaykitRecurrenceUnit.Day, PaykitRecurrenceUnit.Week, PaykitRecurrenceUnit.Month, PaykitRecurrenceUnit.Year) + listOf( + PaykitRecurrenceUnit.Day, + PaykitRecurrenceUnit.Week, + PaykitRecurrenceUnit.Month, + PaykitRecurrenceUnit.Year + ) .forEach { option -> composeTestRule.onNodeWithTag("Tab-${option.name.lowercase()}").performClick() assertEquals(option, frequency) } + composeTestRule.runOnIdle { acceptedMethods = persistentListOf() } + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() + composeTestRule.runOnIdle { acceptedMethods = persistentListOf(MethodId.P2wpkh.rawValue) } composeTestRule.runOnIdle { isLoadingIcon = true } composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() composeTestRule.runOnIdle { isLoadingIcon = false } @@ -164,8 +180,9 @@ class CreateSubscriptionScreenTest { private val subscription = PaykitSubscription( paymentRequestId = "creator-proposal", counterparty = target.publicKey, - amountValue = "0.00001", - amountSats = 1000uL, + amount = PaykitAmount(PaykitAsset.BTC, 1000uL), + paymentReference = "invoice", + pricing = PaykitRequestPricing(), note = "Support", createdAt = startsAt, proposalExpiresAt = Instant.parse("2027-01-22T08:00:00Z"), diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index 37d00e00f9..c92dc2e538 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -2,20 +2,35 @@ package to.bitkit.ui.screens.subscriptions +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import org.junit.Rule import org.junit.Test +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.MethodId +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.screens.paymentrequests.PaymentRequestsContent import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors import kotlin.test.assertTrue import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -25,6 +40,54 @@ class SubscriptionsScreenTest { @get:Rule val composeTestRule = createComposeRule() + @Test + fun subscriptionReviewDisclosesAcceptedMethods() { + val now = Instant.parse("2027-01-15T08:00:00Z") + val subscription = mutableStateOf( + PaykitSubscription( + paymentRequestId = "subscription", + counterparty = "pubky" + "y".repeat(52), + amount = PaykitAmount(PaykitAsset.USD, 300uL), + paymentReference = "invoice", + pricing = PaykitRequestPricing(), + note = null, + createdAt = now, + proposalExpiresAt = Instant.parse("2027-01-22T08:00:00Z"), + recurrence = PaykitSubscriptionRecurrence(1, PaykitRecurrenceUnit.Month, now, now, null), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue), + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + paidPeriods = emptyList(), + ) + ) + composeTestRule.setContent { + AppThemeSurface { + SubscriptionReview( + subscription = subscription.value, + contact = PubkyProfile.forDisplay(subscription.value.counterparty, "Anna", null), + now = now, + onDetails = {}, + onSubscribe = { true }, + ) + } + } + composeTestRule.onNodeWithTag("SubscriptionAcceptedMethods").assertIsDisplayed() + composeTestRule.onNodeWithText("USDT").assertIsDisplayed() + composeTestRule.onNodeWithText("Swipe To Subscribe").assertIsDisplayed() + val pixels = composeTestRule.onNodeWithTag("GRAB").captureToImage().toPixelMap() + assertTrue((0 until pixels.height).any { y -> + (0 until pixels.width).any { x -> pixels[x, y].toArgb() == Colors.Usdt.toArgb() } + }, "USDT subscription swipe must use the USDT accent") + composeTestRule.runOnIdle { + subscription.value = subscription.value.copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2tr.rawValue, MethodId.Lnurl.rawValue), + ) + } + composeTestRule.onNodeWithText("USDT").assertDoesNotExist() + composeTestRule.onNodeWithText("Savings").assertIsDisplayed() + composeTestRule.onNodeWithText("Spending").assertIsDisplayed() + } + @Test fun emptyOverviewOffersCreationWithoutDiscover() { var requestedCreation = false diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt index 2eb7690d56..9370094471 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt @@ -1,18 +1,28 @@ package to.bitkit.ui.screens.wallets.send +import android.accessibilityservice.AccessibilityServiceInfo +import android.view.accessibility.AccessibilityNodeInfo import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test import to.bitkit.models.NodeLifecycleState import to.bitkit.test.annotations.ComposeUi +import to.bitkit.viewmodels.SendFundingOption +import to.bitkit.viewmodels.SendFundingSource import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.previewAmountInputViewModel +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue @ComposeUi class SendAmountContentTest { @@ -23,8 +33,13 @@ class SendAmountContentTest { private val uiState = SendUiState( payMethod = SendMethod.LIGHTNING, amount = 100u, + isAmountInputValid = true, isUnified = true, canSwitchFundingSource = true, + fundingOptions = persistentListOf( + SendFundingOption(SendFundingSource.Savings, enabled = true), + SendFundingOption(SendFundingSource.Spending, enabled = true), + ), ) private val nodeLifecycleState = NodeLifecycleState.Running @@ -62,20 +77,35 @@ class SendAmountContentTest { } @Test - fun whenPaymentMethodButtonClicked_shouldTriggerEvent() { - var eventTriggered = false + fun whenPaymentMethodSelected_shouldTriggerEvent() { + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + automation.serviceInfo = automation.serviceInfo.apply { + flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or + AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + } + var selectedSource: SendFundingSource? = null composeTestRule.setContent { SendAmountContent( nodeLifecycleState = nodeLifecycleState, uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), - onClickPayMethod = { eventTriggered = true } + onClickPayMethod = { selectedSource = it } ) } composeTestRule.onNodeWithTag("AssetButton-switch").performClick() + composeTestRule.onNodeWithTag("SendFundingSources").assertExists() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + automation.windows.any { window -> + window.root?.containsResourceId("SendFundingSources") == true + } + } + composeTestRule.runOnIdle { assertNull(selectedSource) } - assert(eventTriggered) + composeTestRule.onNodeWithText("SAVINGS").performClick() + + composeTestRule.runOnIdle { assertEquals(SendFundingSource.Savings, selectedSource) } + composeTestRule.onNodeWithTag("SendFundingSources").assertDoesNotExist() } @Test @@ -93,7 +123,7 @@ class SendAmountContentTest { composeTestRule.onNodeWithTag("ContinueAmount") .performClick() - assert(eventTriggered) + assertTrue(eventTriggered) } @Test @@ -101,7 +131,7 @@ class SendAmountContentTest { composeTestRule.setContent { SendAmountContent( nodeLifecycleState = nodeLifecycleState, - uiState = uiState.copy(amount = 0u), + uiState = uiState.copy(amount = 0u, isAmountInputValid = false), amountInputViewModel = previewAmountInputViewModel(), ) } @@ -110,15 +140,11 @@ class SendAmountContentTest { } @Test - fun whenFundingSourceLoading_sourceAndContinueButtonsShouldBeDisabled() { + fun whenFundingSourceLoading_sourceIgnoresClicksAndContinueIsDisabled() { composeTestRule.setContent { SendAmountContent( nodeLifecycleState = nodeLifecycleState, - uiState = uiState.copy( - isAmountInputValid = true, - canSwitchFundingSource = true, - isFundingSourceLoading = true, - ), + uiState = uiState.copy(isFundingSourceLoading = true), amountInputViewModel = previewAmountInputViewModel(), ) } @@ -133,11 +159,7 @@ class SendAmountContentTest { composeTestRule.setContent { SendAmountContent( nodeLifecycleState = nodeLifecycleState, - uiState = uiState.copy( - isAmountInputValid = true, - canSwitchFundingSource = true, - isLoading = true, - ), + uiState = uiState.copy(isLoading = true), amountInputViewModel = previewAmountInputViewModel(), onClickPayMethod = { eventTriggered = true }, ) @@ -147,4 +169,9 @@ class SendAmountContentTest { composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled() assertFalse(eventTriggered) } + + private fun AccessibilityNodeInfo.containsResourceId(resourceId: String): Boolean = + viewIdResourceName == resourceId || (0 until childCount).any { + getChild(it)?.containsResourceId(resourceId) == true + } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt index 94a718bad2..917b17d346 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt @@ -30,6 +30,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.UsdtPreferencesRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.LocalSettingsViewModel @@ -53,11 +54,17 @@ class SendConfirmNavigationTest { val composeTestRule = createComposeRule() @Inject lateinit var settingsStore: SettingsStore + @Inject lateinit var widgetsStore: WidgetsStore + @Inject lateinit var pubkyRepo: PubkyRepo + @Inject lateinit var contactPaymentSettingsRepo: ContactPaymentSettingsRepo + @Inject lateinit var widgetsRepo: WidgetsRepo + @Inject lateinit var usdtPreferences: UsdtPreferencesRepo + @Before fun setup() { hiltRule.inject() @@ -74,7 +81,15 @@ class SendConfirmNavigationTest { composeTestRule.setContent { val context = LocalContext.current val settings = viewModel { - SettingsViewModel(context, settingsStore, pubkyRepo, contactPaymentSettingsRepo, widgetsStore, widgetsRepo) + SettingsViewModel( + context, + settingsStore, + pubkyRepo, + contactPaymentSettingsRepo, + widgetsStore, + widgetsRepo, + usdtPreferences + ) } AppThemeSurface { CompositionLocalProvider( diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt index 0406dd27bf..a198312535 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt @@ -35,6 +35,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import to.bitkit.models.FeeRate +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitRecurrenceUnit @@ -76,8 +79,8 @@ class SendConfirmScreenTest { val request = PaykitPaymentRequest( paymentRequestId = "preparing", counterparty = "requester", - amountValue = "5000", - amountSats = 5_000u, + amount = PaykitAmount(PaykitAsset.BTC, 5_000u), + paymentReference = "test-reference", note = "Dinner", expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf("bitcoin"), @@ -138,7 +141,7 @@ class SendConfirmScreenTest { assertEquals(SheetValue.Expanded, sheetState.currentValue) assertEquals(1, visibleCount) state.value = SendUiState( - amount = request.amountSats, + amount = request.amount.atomic, isPaymentRequest = true, isAmountInputValid = true, contactPaymentProfile = contact, @@ -167,8 +170,9 @@ class SendConfirmScreenTest { val subscription = PaykitSubscription( paymentRequestId = "subscription-id", counterparty = "payee", - amountValue = "0.00003", - amountSats = 3_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 3_000uL), + paymentReference = "subscription", + pricing = PaykitRequestPricing(), note = "Journey Sub", createdAt = startsAt, proposalExpiresAt = null, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt index 4a8ed0dfea..a6fdf4900e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt @@ -37,6 +37,7 @@ import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.UsdtPreferencesRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.ui.LocalSettingsViewModel import to.bitkit.ui.theme.AppThemeSurface @@ -79,6 +80,8 @@ class SendPendingObservationDeviceTest { @Inject lateinit var widgetsRepo: WidgetsRepo + @Inject lateinit var usdtPreferences: UsdtPreferencesRepo + @Inject lateinit var vssStoreIdProvider: VssStoreIdProvider @Test @@ -124,6 +127,7 @@ class SendPendingObservationDeviceTest { contactPaymentSettingsRepo, widgetsStore, widgetsRepo, + usdtPreferences, ) var recovered by mutableStateOf?>(null) composeTestRule.setContent { diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtBridgeDetailsTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtBridgeDetailsTest.kt new file mode 100644 index 0000000000..ef1094302a --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtBridgeDetailsTest.kt @@ -0,0 +1,243 @@ +package to.bitkit.ui.screens.wallets.usdt + +import android.graphics.Bitmap +import android.os.SystemClock +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.test.platform.app.InstrumentationRegistry +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDepositDetail +import com.synonym.bitkitcore.UsdtDepositNetwork +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtOrchestraTransfer +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import java.io.File +import java.util.Locale + +@HiltAndroidTest +@ComposeUi +class UsdtBridgeDetailsTest { + private val originalLocale = Locale.getDefault() + private val address = "0x1111111111111111111111111111111111111111" + private val sourceTx = "0x" + "ab".repeat(32) + private val deposit = UsdtDeposit( + "xit_4cfc646b-3657-4144-a644-b7880f184686", "bsc", "USDT", 50_000_001u, + sourceTx, "held", null, null, + ) + private val transfer = UsdtTransfer( + id = "transfer", sender = address, txHash = sourceTx, userOperationHash = null, + bridgeGuid = null, orchestra = null, recipient = "0x2222222222222222222222222222222222222222", + destination = UsdtDestination.ARBITRUM, amount = 50_000_000u, receivedAmount = 50_000_000u, + fee = 50_000u, isIncoming = true, status = UsdtTransferStatus.PENDING, timestamp = 1_752_500_220u, + ) + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setup() { + Locale.setDefault(Locale.US) + hiltRule.inject() + } + + @After + fun teardown() { Locale.setDefault(originalLocale) } + + @Test + fun incomingReceiptDoesNotInventProviderDepositOrRefund() { + composeTestRule.setContent { Frame { UsdtActivityDetail(transfer, false, address) {} } } + composeTestRule.onNodeWithText("Receiving USDT").assertIsDisplayed() + composeTestRule.onNodeWithText("Pending").assertIsDisplayed() + composeTestRule.onNodeWithText("RECEIVING NETWORK").assertIsDisplayed() + assertSummaryDimensions() + screenshot("receiving") + composeTestRule.onNodeWithTag("UsdtActivityDetails").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(sourceTx).assertIsDisplayed() + composeTestRule.onNodeWithText("DEPOSIT REFERENCE").assertDoesNotExist() + composeTestRule.onNodeWithTag("UsdtRefundAddress").assertDoesNotExist() + composeTestRule.onNodeWithTag("UsdtActivityExplorer").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("UsdtDepositHistory").assertIsDisplayed() + } + + @Test + fun deliveringTransferKeepsRealNetworksAndBridgeReferences() { + val outgoing = transfer.copy( + isIncoming = false, status = UsdtTransferStatus.BRIDGING, destination = UsdtDestination.TRON, + amount = 10_000_000u, receivedAmount = 9_990_000u, fee = 40_000u, + recipient = "TVeZbACe9p6gqKQbQJHG5XKjJG5BBR5rXG", + orchestra = UsdtOrchestraTransfer("q_01a1319f-3db4-7457-94cf-7cffa86a466f", address, null, null, null), + ) + composeTestRule.setContent { Frame { UsdtActivityDetail(outgoing, false, address) {} } } + composeTestRule.onNodeWithText("Delivering USDT").assertIsDisplayed() + composeTestRule.onNodeWithText("Delivering").assertIsDisplayed() + composeTestRule.onNodeWithText("Tron").assertIsDisplayed() + screenshot("delivering") + composeTestRule.onNodeWithTag("UsdtActivityDetails").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("SENDER NETWORK").assertIsDisplayed() + composeTestRule.onNodeWithText("RECIPIENT NETWORK").assertIsDisplayed() + composeTestRule.onNodeWithText("Orchestra").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText(requireNotNull(outgoing.orchestra).quoteId).performScrollTo().assertIsDisplayed() + screenshot("delivering-details") + composeTestRule.onNodeWithTag("UsdtActivityExplorer").assertIsDisplayed() + waitForClickDebounce() + composeTestRule.onNodeWithTag("UsdtActivityDetails").performScrollTo().performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Delivering").performScrollTo().assertIsDisplayed() + } + + @Test + fun providerRefundShowsSourceNetworkAndLocalizedAmountAndHonorsEligibility() { + Locale.setDefault(Locale.GERMANY) + val state = mutableStateOf(DepositUiState(deposits = persistentListOf(deposit))) + val refundAddress = mutableStateOf("") + val showConfirmation = mutableStateOf(false) + composeTestRule.setContent { + Frame { + UsdtDepositsContent( + state = state.value, address = refundAddress.value, hideBalance = false, + onBack = { state.value = state.value.copy(detail = null) }, + onAddress = { refundAddress.value = it }, + onDetail = { state.value = state.value.copy(detail = UsdtDepositDetail(deposit, null)) }, + onHistory = { state.value = state.value.copy(detail = null) }, onMore = {}, onRefresh = {}, + onRefund = { showConfirmation.value = true }, + ) + if (showConfirmation.value) { + UsdtRefundDialog( + amount = deposit.amount, hideBalance = false, network = UsdtDepositNetwork.BSC, + address = refundAddress.value, onCancel = { showConfirmation.value = false }, onConfirm = {}, + ) + } + } + } + composeTestRule.onNodeWithText("Needs attention").assertIsDisplayed() + composeTestRule.onNodeWithText("BNB Smart Chain").assertIsDisplayed() + assertHeight("UsdtDepositRow-" + deposit.id, 72f) + screenshot("deposits") + composeTestRule.onNodeWithTag("UsdtDepositRow-" + deposit.id).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("RECEIVING NETWORK").assertIsDisplayed() + composeTestRule.onNodeWithTag("UsdtRefundAddress").assertDoesNotExist() + composeTestRule.onNodeWithTag("UsdtDepositDetails").performScrollTo().performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("USDT address on BSC").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("UsdtDepositRefund").assertIsNotEnabled() + assertHeight("UsdtRefundAddress", 52f) + screenshot("receiving-details") + val refundField = composeTestRule.onNodeWithTag("UsdtRefundAddress") + refundField.performTextInput(address.take(20)) + composeTestRule.runOnIdle { + state.value = state.value.copy(detail = UsdtDepositDetail(deposit.copy(status = "below_minimum"), null)) + } + refundField.assertIsFocused().performTextInput(address.drop(20)) + refundField.assertTextContains(address) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("UsdtDepositRefund").performScrollTo().performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Confirm Refund").assertIsDisplayed() + composeTestRule.onNodeWithText("50,00 USDT", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Refund address:\n" + address, substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("BNB Smart Chain (BSC)", substring = true).assertIsDisplayed() + screenshot("refund") + composeTestRule.onNodeWithTag("UsdtRefundCancel").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("UsdtDepositDetails").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Needs attention").assertIsDisplayed() + waitForClickDebounce() + composeTestRule.onNodeWithTag("UsdtDepositDetails").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(address).performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("UsdtDepositHistory").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("UsdtDepositRow-" + deposit.id).assertIsDisplayed().performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("NavigationBack").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("UsdtDepositRow-" + deposit.id).assertIsDisplayed().performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("UsdtDepositDetails").performScrollTo().performClick() + composeTestRule.waitForIdle() + for (status in listOf("completed", "refunded", "refunding", "refund_requested")) { + composeTestRule.runOnIdle { + state.value = state.value.copy(detail = UsdtDepositDetail(deposit.copy(status = status), null)) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("UsdtDepositRefund").assertDoesNotExist() + composeTestRule.onNodeWithTag("UsdtRefundAddress").assertDoesNotExist() + } + } + + @Composable + private fun Frame(content: @Composable () -> Unit) { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + Box(modifier = Modifier.fillMaxSize()) { content() } + } + } + } + + private fun assertSummaryDimensions() { + val density = InstrumentationRegistry.getInstrumentation().targetContext.resources.displayMetrics.density + val fiat = composeTestRule.onNodeWithText("$ 50.00").fetchSemanticsNode().boundsInRoot + val status = composeTestRule.onNodeWithText("STATUS").fetchSemanticsNode().boundsInRoot + val date = composeTestRule.onNodeWithText("DATE").fetchSemanticsNode().boundsInRoot + val value = composeTestRule.onNodeWithText("Pending").fetchSemanticsNode().boundsInRoot + assertEquals("Caption line height", 18f, status.height / density, 0.5f) + assertEquals("Value line height", 20f, value.height / density, 0.5f) + assertEquals("Amount header and section gap", 102f, (status.top - fiat.top) / density, 1f) + assertEquals("Detail row cadence", 78f, (date.top - status.top) / density, 1f) + } + + private fun assertHeight(tag: String, expectedDp: Float) { + val density = InstrumentationRegistry.getInstrumentation().targetContext.resources.displayMetrics.density + val bounds = composeTestRule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + assertEquals(tag, expectedDp, bounds.height / density, 0.5f) + } + + // PrimaryButton debounces repeat clicks for 500 ms. + private fun waitForClickDebounce() = SystemClock.sleep(600) + + private fun screenshot(name: String) { + if (InstrumentationRegistry.getArguments().getString("bridgeScreenshots") != "true") return + val instrumentation = InstrumentationRegistry.getInstrumentation() + composeTestRule.waitForIdle() + instrumentation.waitForIdleSync() + SystemClock.sleep(1000) + val image = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + File(instrumentation.targetContext.getExternalFilesDir(null), "bridge-$name.png").outputStream().use { + check(image.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + image.recycle() + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt new file mode 100644 index 0000000000..76a2da4cd5 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt @@ -0,0 +1,127 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.UsdtBridgeProvider +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtQuote +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import java.util.Locale +import kotlin.test.assertTrue + +@HiltAndroidTest +@ComposeUi +class UsdtPaymentContentTest { + private val originalLocale = Locale.getDefault() + private val quote = UsdtQuote( + id = "bridge", + recipient = "0x1111111111111111111111111111111111111111", + destination = UsdtDestination.POLYGON, + bridgeProvider = UsdtBridgeProvider.ORCHESTRA, + amount = 3_000_000u, + receivedAmount = 2_990_000u, + maximumFee = 1_470_000u, + expiresAt = 100u, + ) + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setup() { + Locale.setDefault(Locale.US) + hiltRule.inject() + } + + @After + fun teardown() { + Locale.setDefault(originalLocale) + } + + @Test + fun bridgeMaximumDebitRemainsVisibleWithoutScrolling() { + composeTestRule.setContent { Content(UsdtSendState(quote = quote), UsdtPage.AMOUNT) } + val total = composeTestRule.onNodeWithTag("UsdtMaximumTotal") + val swipe = composeTestRule.onNodeWithTag("UsdtConfirm") + composeTestRule.onNodeWithText("YOU SEND").assertIsDisplayed() + composeTestRule.onNodeWithTag("UsdtRecipientAmount").assertIsDisplayed() + composeTestRule.onNodeWithText("≈ 2.99 USDT").assertIsDisplayed() + total.assertIsDisplayed() + composeTestRule.onNodeWithText("4.47 USDT").assertIsDisplayed() + swipe.assertIsDisplayed() + assertTrue(total.fetchSemanticsNode().boundsInRoot.bottom <= swipe.fetchSemanticsNode().boundsInRoot.top) + composeTestRule.onNodeWithTag("UsdtReviewDetails").performScrollTo().performClick() + composeTestRule.onNodeWithTag("UsdtRecipientAmount").assertIsDisplayed() + total.assertIsDisplayed() + swipe.assertIsDisplayed() + val cost = composeTestRule.onNodeWithText("0.01 USDT") + cost.performScrollTo().assertIsDisplayed() + assertTrue(cost.fetchSemanticsNode().boundsInRoot.bottom <= total.fetchSemanticsNode().boundsInRoot.top) + composeTestRule.onNodeWithText( + "Delivery is estimated. Bridge costs are deducted from the amount; the network fee is additional." + ).performScrollTo().assertIsDisplayed() + total.assertIsDisplayed() + swipe.assertIsDisplayed() + } + + @Test + fun closingPaymentDoesNotOpenRecipientEntry() { + val page = mutableStateOf(UsdtPage.AMOUNT) + val state = mutableStateOf(UsdtSendState(quote = quote, submitted = true)) + composeTestRule.setContent { + Content(state.value, page.value) { + state.value = UsdtSendState() + page.value = UsdtPage.WALLET + } + } + composeTestRule.onNodeWithText("Details").performClick() + composeTestRule.onNodeWithTag("UsdtManual").assertDoesNotExist() + composeTestRule.onNodeWithTag("UsdtPaste").assertDoesNotExist() + } + + @Composable + private fun Content(state: UsdtSendState, page: UsdtPage, onDetails: () -> Unit = {}) { + AppThemeSurface { + Box(modifier = Modifier.size(width = 360.dp, height = 600.dp)) { + UsdtPaymentContent( + wallet = UsdtWalletState(), + state = state, + page = page, + recipient = quote.recipient, + amount = "3", + destination = quote.destination, + onRecipientChange = {}, + onAmountChange = {}, + onRecipientContinue = {}, + onPageChange = {}, + onBack = {}, + onReview = {}, + onConfirm = {}, + onDone = {}, + onDetails = onDetails, + ) + } + } + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentNavigationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentNavigationTest.kt new file mode 100644 index 0000000000..9526fa9f9a --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentNavigationTest.kt @@ -0,0 +1,107 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.material3.Text +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.testing.TestNavHostController +import androidx.navigation.toRoute +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtQuote +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.Routes +import to.bitkit.ui.navigateToUsdtPayment +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@ComposeUi +class UsdtPaymentNavigationTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun paymentProgressSurvivesRecreationWithoutRestoringAnEditableDraftAfterProcessDeath() { + val restoration = StateRestorationTester(composeTestRule) + var sendState = mutableStateOf(UsdtSendState()) + lateinit var page: MutableState + restoration.setContent { + val state by remember { sendState } + page = rememberUsdtPage("owned recipient", state) + Text(page.value.name) + } + restoration.emulateSavedInstanceStateRestore() + composeTestRule.onNodeWithText(UsdtPage.AMOUNT.name).assertExists() + + val quote = UsdtQuote( + id = "payment", recipient = "owned recipient", destination = UsdtDestination.ARBITRUM, + bridgeProvider = null, amount = 1u, receivedAmount = 1u, maximumFee = 1u, expiresAt = 100u, + ) + for (state in listOf( + UsdtSendState(quote = quote, busy = true), + UsdtSendState(quote = quote, busy = true, submitted = true), + UsdtSendState(quote = quote, submitted = true), + )) { + composeTestRule.runOnIdle { + page.value = UsdtPage.AMOUNT + sendState.value = state + } + restoration.emulateSavedInstanceStateRestore() + composeTestRule.onNodeWithText(UsdtPage.AMOUNT.name).assertExists() + + composeTestRule.runOnIdle { sendState = mutableStateOf(UsdtSendState()) } + restoration.emulateSavedInstanceStateRestore() + composeTestRule.onNodeWithText(UsdtPage.WALLET.name).assertExists() + } + } + + @Test + fun dismissedPaymentLinksOpenFreshEntriesWithCurrentArguments() { + lateinit var navController: TestNavHostController + lateinit var page: MutableState + composeTestRule.setContent { + val context = LocalContext.current + val controller = remember { + TestNavHostController(context).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + } + } + navController = controller + NavHost(navController = controller, startDestination = Routes.Home) { + composable { Text("Home") } + composable { entry -> + val payment = entry.toRoute() + page = rememberUsdtPage(payment.recipient, UsdtSendState()) + Text("${page.value} ${payment.recipient} ${payment.amount}") + } + } + } + val first = Routes.UsdtPayment("first recipient", "1") + composeTestRule.runOnIdle { navController.navigateToUsdtPayment(first) } + val oldId = navController.currentBackStackEntry?.id + for (payment in listOf(first, Routes.UsdtPayment("second recipient", "2.5"))) { + composeTestRule.runOnIdle { page.value = UsdtPage.WALLET } + composeTestRule.runOnIdle { navController.navigateToUsdtPayment(payment) } + composeTestRule.runOnIdle { + assertNotEquals(oldId, navController.currentBackStackEntry?.id) + assertEquals(payment, navController.currentBackStackEntry?.toRoute()) + assertEquals(UsdtPage.AMOUNT, page.value) + } + } + composeTestRule.runOnIdle { + navController.popBackStack() + assertTrue(navController.currentDestination?.hasRoute() == true) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8758ef22d1..70693df494 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -116,6 +116,7 @@ + @@ -139,6 +140,7 @@ + diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 6bb1d5436f..bbc90c35a9 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -11,6 +11,7 @@ import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import to.bitkit.data.serializers.SettingsSerializer import to.bitkit.env.Env @@ -174,6 +175,8 @@ data class SettingsData( val publicPaykitCleanupPending: Boolean = false, val publicPaykitLightningEnabled: Boolean = true, val publicPaykitOnchainEnabled: Boolean = true, + val publicPaykitUsdtEnabled: Boolean = true, + val usdtPreference: UsdtPreference = UsdtPreference.UNDECIDED, val publicPaykitBolt11: String = "", val publicPaykitBolt11PaymentHash: String = "", val publicPaykitBolt11ExpiresAtMillis: Long = 0, @@ -281,3 +284,18 @@ fun SettingsData.paykitDisabled(markPublicCleanupPending: Boolean = false) = cop publicPaykitBolt11PaymentHash = "", publicPaykitBolt11ExpiresAtMillis = 0, ) + +@Serializable +enum class UsdtPreference { + @SerialName("undecided") + UNDECIDED, + + @SerialName("disabled") + DISABLED, + + @SerialName("enabled") + ENABLED, +} + +val SettingsData.isUsdtEnabled: Boolean + get() = Env.isUsdtEnabled && usdtPreference == UsdtPreference.ENABLED diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 193f2f5d23..f543cfe859 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -252,6 +252,7 @@ class Keychain @Inject constructor( PAYKIT_PENDING_PAYMENT_PROOFS, ONCHAIN_SEND_ATTEMPT, PAYKIT_ACCEPTED_PAYMENT_REQUESTS, + PAYKIT_USDT_PAYMENTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index dd5d4ab8a7..f22b86336b 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -1,6 +1,7 @@ package to.bitkit.env import android.os.Build +import com.synonym.bitkitcore.UsdtDestination import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PeerDetails @@ -17,6 +18,23 @@ import com.synonym.bitkitcore.Network as BitkitCoreNetwork @Suppress("ConstPropertyName", "KotlinConstantConditions", "SimplifyBooleanWithConstants") internal object Env { + val isUsdtEnabled: Boolean get() = (network == Network.BITCOIN || isDebug) && + usdtRpcUrl != null && usdtBundlerUrl.isNotBlank() + val usdtBundlerUrl: String get() = BuildConfig.USDT_BUNDLER_URL + val usdtDepositsUrl: String? get() = BuildConfig.USDT_DEPOSITS_URL.takeIf { it.isNotBlank() } + val usdtBridgesUrl: String? get() = BuildConfig.USDT_BRIDGES_URL.takeIf { it.isNotBlank() } + val usdtRpcUrl: String? get() = BuildConfig.USDT_RPC_URL.takeIf { it.isNotBlank() } + + val usdtDestinations: List get() { + val enabled = BuildConfig.USDT_BRIDGE_NETWORKS.split(',').map { it.trim() }.toSet() + return listOf(UsdtDestination.ARBITRUM) + listOf( + "ethereum" to UsdtDestination.ETHEREUM, + "polygon" to UsdtDestination.POLYGON, + "plasma" to UsdtDestination.PLASMA, + "stable" to UsdtDestination.STABLE, + ).filter { it.first in enabled }.map { it.second } + } + val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index b5be0c4e61..c52781198c 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -24,6 +24,7 @@ data class WalletBackupV1( val paykitSdkBackupState: String? = null, val watchOnlyAccounts: List? = null, val watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? = null, + val usdtWallet: String? = null, val paykitPaymentState: PaykitPaymentStateBackup? = null, ) diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 004c985fd1..3963b4b524 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -12,6 +12,8 @@ data class NewTransactionSheetDetails( val activityId: String? = null, val activityWalletId: String? = null, val sats: Long = 0, + val usdtAmount: ULong? = null, + val usdtTransferId: String? = null, val isLoadingDetails: Boolean = false, ) { companion object { diff --git a/app/src/main/java/to/bitkit/models/PaykitAmount.kt b/app/src/main/java/to/bitkit/models/PaykitAmount.kt new file mode 100644 index 0000000000..a9a8f1642d --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PaykitAmount.kt @@ -0,0 +1,112 @@ +package to.bitkit.models + +import kotlinx.serialization.Serializable +import to.bitkit.utils.AppError +import java.math.BigDecimal +import java.math.RoundingMode + +@Serializable +enum class PaykitAsset(val code: String, val decimals: Int) { + BTC("btc", 8), + USD("usd", 2), + USDT("usdt", 6), +} + +sealed class PaykitAmountError(message: String) : AppError(message) { + data object InvalidAmount : PaykitAmountError("Invalid payment amount") + data object RateUnavailable : PaykitAmountError("Payment rates are unavailable or expired") +} + +@Serializable +data class PaykitExchangeRate(val price: String, val timestampMillis: Long) { + companion object { + const val MAX_AGE_MILLIS = 10 * 60 * 1000L + private const val MAX_PRICE_DIGITS = 18 + } + + fun value(nowMillis: Long): BigDecimal { + val isFresh = timestampMillis <= nowMillis && nowMillis - timestampMillis <= MAX_AGE_MILLIS + val isDecimal = price.count { it != '.' } <= MAX_PRICE_DIGITS && + price.matches(Regex("[0-9]+(?:\\.[0-9]+)?")) + if (!isFresh || !isDecimal) { + throw PaykitAmountError.RateUnavailable + } + return price.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + ?: throw PaykitAmountError.RateUnavailable + } +} + +@Serializable +data class PaykitAmount(val asset: PaykitAsset, val atomic: ULong) { + companion object { + /** Precision shared with the native decimal implementation. */ + private const val MAX_QUOTE_DIGITS = 38 + + /** Maximum accepted decimal rate text length. */ + private const val MAX_RATE_LENGTH = 80 + + fun parse(asset: PaykitAsset, value: String): PaykitAmount { + val scaled = value.takeIf { it.matches(Regex("[0-9]+(?:\\.[0-9]+)?")) } + ?.toBigDecimalOrNull()?.movePointRight(asset.decimals) + ?: throw PaykitAmountError.InvalidAmount + if (scaled.stripTrailingZeros().scale() > 0 || scaled <= BigDecimal.ZERO) { + throw PaykitAmountError.InvalidAmount + } + return PaykitAmount(asset, atomic(scaled)) + } + + fun parseInput(asset: PaykitAsset, value: String): PaykitAmount = parse(asset, value.trim().removeSuffix(".")) + + private fun atomic(value: BigDecimal): ULong = value.setScale(0, RoundingMode.CEILING) + .toPlainString().toULongOrNull() ?: throw PaykitAmountError.InvalidAmount + } + + val value: String get() = decimalValue().stripTrailingZeros().toPlainString() + + fun convertedTo(paymentAsset: PaykitAsset, rate: PaykitExchangeRate?, nowMillis: Long): PaykitAmount { + val value = valueIn(paymentAsset, rate, nowMillis, paymentAsset.decimals) + return PaykitAmount(paymentAsset, atomic(value.movePointRight(paymentAsset.decimals))) + } + + fun quotedTo(asset: PaykitAsset, multiplier: String): PaykitAmount { + val rate = multiplier.takeIf { + it.length <= MAX_RATE_LENGTH && it.matches(Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)")) + }?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + ?: throw PaykitAmountError.RateUnavailable + val product = atomic.toString().toBigDecimal().multiply(rate).stripTrailingZeros() + if (product.precision() > MAX_QUOTE_DIGITS) throw PaykitAmountError.InvalidAmount + return PaykitAmount(asset, atomic(product.scaleByPowerOfTen(asset.decimals - this.asset.decimals))) + } + + private fun valueIn(target: PaykitAsset, rate: PaykitExchangeRate?, nowMillis: Long, scale: Int): BigDecimal { + val amount = decimalValue() + if ((asset == PaykitAsset.BTC) == (target == PaykitAsset.BTC)) return amount + val price = rate?.value(nowMillis) ?: throw PaykitAmountError.RateUnavailable + return if (asset == PaykitAsset.BTC) { + amount.multiply(price) + } else { + amount.divide(price, scale, RoundingMode.CEILING) + } + } + + private fun decimalValue() = atomic.toString().toBigDecimal().movePointLeft(asset.decimals) +} + +class PaykitAmountConversion { + private var original: PaykitAmount? = null + private var converted: PaykitAmount? = null + + fun convert( + input: PaykitAmount?, + asset: PaykitAsset, + rate: PaykitExchangeRate?, + nowMillis: Long, + ): PaykitAmount { + val source = if (input == converted) original else input + val result = source?.takeIf { it.atomic > 0uL }?.convertedTo(asset, rate, nowMillis) + ?: PaykitAmount(asset, 0uL) + original = source + converted = result + return result + } +} diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index efbdb50c7e..7cb9da325c 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -17,6 +17,7 @@ data class PaykitPaymentStateBackup( val subscriptions: Map, val pendingProofs: List, val activeOnchainAttempt: ActiveOnchainAttemptBackup? = null, + val usdt: PaykitUsdtStateBackup? = null, val acceptedOneTimeRequests: Map>? = null, ) { @Serializable @@ -48,6 +49,7 @@ data class PaykitPaymentStateBackup( val paymentStarted: Boolean, val paymentIdentifier: String? = null, val proofData: String? = null, + val conversionQuoteId: String? = null, val billingPeriod: PaykitBillingPeriod? = null, val onchainAddress: String? = null, val onchainAmountSats: ULong? = null, @@ -78,6 +80,7 @@ data class PaykitPaymentStateBackup( paymentStarted = proof.paymentStarted, paymentIdentifier = proof.paymentIdentifier, proofData = proof.proofData, + conversionQuoteId = proof.conversionQuoteId, billingPeriod = proof.billingPeriod, onchainAddress = proof.onchainAddress, onchainAmountSats = proof.onchainAmountSats, @@ -102,6 +105,7 @@ data class PaykitPaymentStateBackup( paymentStarted = paymentStarted, paymentIdentifier = paymentIdentifier, proofData = proofData, + conversionQuoteId = conversionQuoteId, billingPeriod = billingPeriod, onchainAddress = onchainAddress, onchainAmountSats = onchainAmountSats, diff --git a/app/src/main/java/to/bitkit/models/PaykitRequestPricing.kt b/app/src/main/java/to/bitkit/models/PaykitRequestPricing.kt new file mode 100644 index 0000000000..1d493cc6b3 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PaykitRequestPricing.kt @@ -0,0 +1,152 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.models + +import com.synonym.paykit.ConversionRate +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentConversionQuoteRecord +import com.synonym.paykit.PaymentDeadline +import kotlinx.serialization.Serializable +import to.bitkit.repositories.MethodId +import to.bitkit.repositories.PaykitBillingPeriod +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** Market rates are only used when creating request terms. */ +data class PaykitRequestPricing( + val conversion: PaymentConversion? = null, + val deadline: PaymentDeadline? = null, + val quotes: List = emptyList(), +) { + companion object { + fun subscriptionEndpoints(requested: PaykitAsset, available: List): List = available.filter { + val method = MethodId.fromRawValue(it) ?: return@filter false + when (requested) { + PaykitAsset.BTC -> method != MethodId.UsdtArbitrum + PaykitAsset.USD -> method == MethodId.UsdtArbitrum + PaykitAsset.USDT -> false + } + } + + fun rates( + requested: PaykitAsset, + endpoints: List, + market: PaykitExchangeRate?, + at: Instant, + ): List = endpoints.mapNotNull { MethodId.fromRawValue(it) } + .map { if (it == MethodId.UsdtArbitrum) PaykitAsset.USDT else PaykitAsset.BTC } + .distinct().sortedBy { it.code }.filter { it != requested }.map { asset -> + val value = if ((requested == PaykitAsset.BTC) != (asset == PaykitAsset.BTC)) { + val price = market?.value(at.toEpochMilliseconds()) ?: throw PaykitAmountError.RateUnavailable + if (requested == PaykitAsset.BTC) { + price + } else { + BigDecimal.ONE.divide( + price, + 18, + RoundingMode.HALF_EVEN + ) + } + } else { + BigDecimal.ONE + } + if (value <= BigDecimal.ZERO) throw PaykitAmountError.RateUnavailable + ConversionRate(asset.code, value.stripTrailingZeros().toPlainString()) + } + } + + fun payment( + requested: PaykitAmount, + endpoint: String, + period: PaykitBillingPeriod?, + at: Instant, + quoteId: String? = null, + ): PaykitRequestPayment { + val parts = endpoint.split("-") + val asset = PaykitAsset.entries.firstOrNull { it.code == parts[0] } + if (asset == null || parts.size != 3 || parts.any { !it.matches(Regex("[a-z0-9]+")) }) { + throw PaykitAmountError.InvalidAmount + } + val deadline = paymentDeadline(period) + val (rates, selected) = if (conversion == PaymentConversion.PerPeriod && requested.asset == asset) { + emptyList() to quoteId?.let { periodQuote(period, at, it) } + } else { + conversionRates(period, at, quoteId) + } + val selector = parts.take(2).joinToString("-") + val rate = rates.firstOrNull { it.asset == selector } ?: rates.firstOrNull { it.asset == asset.code } + if (rate == null && requested.asset != asset) throw PaykitAmountError.RateUnavailable + return PaykitRequestPayment( + amount = requested.quotedTo(asset, rate?.value ?: "1"), + quoteId = selected?.eventId, + validFrom = selected?.let { Instant.parse(it.validFrom) }, + expiresAt = listOfNotNull(deadline, selected?.let { Instant.parse(it.expiresAt) }).minOrNull(), + ) + } + + private fun conversionRates( + period: PaykitBillingPeriod?, + at: Instant, + quoteId: String?, + ): Pair, PaymentConversionQuoteRecord?> { + var selected: PaymentConversionQuoteRecord? = null + val rates = when (val terms = conversion) { + is PaymentConversion.Fixed -> { + if ( + quoteId != null || terms.rates.isEmpty() || + terms.rates.distinctBy { it.asset }.size != terms.rates.size + ) { + throw PaykitAmountError.InvalidAmount + } + terms.rates + } + PaymentConversion.PerPeriod -> { + selected = periodQuote(period, at, quoteId) + selected.rates + } + null -> { + if (quoteId != null) throw PaykitAmountError.InvalidAmount + emptyList() + } + } + return rates to selected + } + + private fun periodQuote(period: PaykitBillingPeriod?, at: Instant, quoteId: String?): PaymentConversionQuoteRecord { + if (period == null) throw PaykitAmountError.InvalidAmount + return quotes.lastOrNull { + Instant.parse(it.billingPeriod.startsAt) == period.startsAt && + Instant.parse(it.billingPeriod.endsAt) == period.endsAt && + if (quoteId != null) { + it.eventId == quoteId + } else { + at >= Instant.parse(it.validFrom) && at <= Instant.parse(it.expiresAt) + } + } ?: throw PaykitAmountError.RateUnavailable + } + + fun paymentDeadline(period: PaykitBillingPeriod?): Instant? = when (val value = deadline) { + null -> null + is PaymentDeadline.At -> { + if (period != null) throw PaykitAmountError.InvalidAmount + Instant.parse(value.timestamp) + } + is PaymentDeadline.PeriodStart -> { + if (period == null || value.seconds > Long.MAX_VALUE.toULong()) throw PaykitAmountError.InvalidAmount + period.startsAt + value.seconds.toLong().seconds + } + } +} + +@Serializable +data class PaykitRequestPayment( + val amount: PaykitAmount, + val quoteId: String?, + val validFrom: Instant?, + val expiresAt: Instant?, +) { + fun isValid(at: Instant): Boolean = (validFrom == null || at >= validFrom) && (expiresAt == null || at <= expiresAt) +} diff --git a/app/src/main/java/to/bitkit/models/PaykitUsdt.kt b/app/src/main/java/to/bitkit/models/PaykitUsdt.kt new file mode 100644 index 0000000000..e2fefa70c9 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PaykitUsdt.kt @@ -0,0 +1,54 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.usdtParsePaymentRequest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import to.bitkit.repositories.Endpoint +import to.bitkit.repositories.MethodId +import to.bitkit.repositories.PublicPaykitError + +object PaykitUsdt { + /** Arbitrum One's chain identifier. */ + const val CHAIN_ID = "42161" + + /** USDT0's pinned Arbitrum token contract. */ + const val TOKEN = "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9" + + private const val ADDRESS_LENGTH = 42 + + fun address(payload: String): String? = runCatching { + val fields = Json.parseToJsonElement(payload).jsonObject + val hasStringFields = listOf("chain_id", "token", "value").all { fields[it]?.jsonPrimitive?.isString == true } + if (!hasStringFields) return@runCatching null + if (fields["chain_id"]?.jsonPrimitive?.contentOrNull != CHAIN_ID || + fields["token"]?.jsonPrimitive?.contentOrNull?.lowercase() != TOKEN + ) { + return@runCatching null + } + val value = fields["value"]?.jsonPrimitive?.contentOrNull ?: return@runCatching null + if (value.length != ADDRESS_LENGTH) return@runCatching null + usdtParsePaymentRequest(value).takeIf { it.chainId == null && it.amount == null }?.recipient + }.getOrNull() + + fun endpoint(address: String): Endpoint { + val payload = JsonObject( + mapOf( + "value" to JsonPrimitive(address), + "chain_id" to JsonPrimitive(CHAIN_ID), + "token" to JsonPrimitive(TOKEN) + ) + ) + .toString() + return Endpoint( + methodId = MethodId.UsdtArbitrum, + value = address(payload) ?: throw PublicPaykitError.InvalidPayload, + rawPayload = payload, + ) + } + + fun paymentUri(address: String): String = "ethereum:$TOKEN@$CHAIN_ID/transfer?address=$address" +} diff --git a/app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt new file mode 100644 index 0000000000..03e4a1c3ae --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt @@ -0,0 +1,38 @@ +package to.bitkit.models + +import kotlinx.serialization.Serializable +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitUsdtAttempt +import to.bitkit.repositories.PaykitUsdtReceipt + +@Serializable +data class PaykitUsdtStateBackup( + val attempts: List, + val receipts: List, +) { + @Serializable + data class Receipt( + val wallet: String, + val identity: String, + val requestId: PaykitPaymentRequestId, + val paymentId: String, + val proofEventId: String, + val verified: Boolean, + val transferId: String, + val amountAtomic: ULong, + val receivedAtMillis: Long, + val underpaid: Boolean, + val afterExpiry: Boolean, + ) { + constructor(value: PaykitUsdtReceipt) : this( + value.wallet, value.identity, value.requestId, value.paymentId, value.proofEventId, + value.verified, value.transferId, value.amount.atomic, value.receivedAtMillis, + value.underpaid, value.afterExpiry, + ) + + fun restored() = PaykitUsdtReceipt( + wallet, identity, requestId, paymentId, proofEventId, verified, transferId, + PaykitAmount(PaykitAsset.USDT, amountAtomic), receivedAtMillis, underpaid, afterExpiry, + ) + } +} diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt index 7fe7b28ab3..4a4461b6fe 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt @@ -1,6 +1,12 @@ package to.bitkit.models +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put import java.nio.ByteBuffer +import java.util.Base64 object PubkyAuthClaimCodec { /** Size of the versioned account metadata and serialized extended public key. */ @@ -13,6 +19,10 @@ object PubkyAuthClaimCodec { const val COMBINED_PAYLOAD_LENGTH = 124 fun validateAccountPayload(claim: PubkyAuthClaim, accountPayload: ByteArray) { + if (claim.sharesUsdt) { + require(accountPayload.isNotEmpty()) { "Invalid payment-details claim" } + return + } if (!claim.includesWatchOnlyAccount) { require(accountPayload.isEmpty()) { "Paykit-only approval cannot include an account" } return @@ -36,6 +46,14 @@ object PubkyAuthClaimCodec { } require(generation != null && generation > 0uL) { "Invalid Paykit key generation" } require(secret?.size == 32) { "Invalid Paykit identity secret length" } + if (claim.sharesUsdt) { + val payload = Json.parseToJsonElement(accountPayload.decodeToString()).jsonObject.toMutableMap() + payload["paykit_access"] = buildJsonObject { + put("key_generation", Json.parseToJsonElement(generation.toString())) + put("secret", Base64.getUrlEncoder().withoutPadding().encodeToString(secret)) + } + return JsonObject(payload).toString().encodeToByteArray() + } val prefix = if (claim.includesWatchOnlyAccount) accountPayload else byteArrayOf(1) val length = if (claim.includesWatchOnlyAccount) COMBINED_PAYLOAD_LENGTH else PAYKIT_PAYLOAD_LENGTH return ByteBuffer.allocate(length) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 0953f9b4c9..ddab9391d0 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -3,6 +3,12 @@ package to.bitkit.models import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import to.bitkit.repositories.Endpoint +import to.bitkit.repositories.MethodId import to.bitkit.utils.AppError import java.net.URI import java.net.URLDecoder @@ -21,13 +27,49 @@ value class PubkyAuthClaim private constructor(val items: ImmutableList) { enum class Item(val wireValue: String) { PAYKIT_ACCESS_V1("paykit-access-v1"), WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), + USDT_ADDRESS_V1("usdt-address-v1"), } val wireValue: String get() = items.joinToString(".") { it.wireValue } - val includesWatchOnlyAccount: Boolean get() = Item.WATCH_ONLY_ACCOUNT_V1 in items + val includesWatchOnlyAccount: Boolean + get() = Item.WATCH_ONLY_ACCOUNT_V1 in items val includesPaykitAccess: Boolean get() = Item.PAYKIT_ACCESS_V1 in items + val sharesBitcoin: Boolean get() = includesWatchOnlyAccount + val sharesUsdt: Boolean get() = Item.USDT_ADDRESS_V1 in items + val sharesReceivingDetails: Boolean get() = sharesBitcoin || sharesUsdt + + fun unsignedPayload(bitcoin: PreparedWatchOnlyAccountClaim?, usdt: Endpoint?): ByteArray { + if (sharesBitcoin && bitcoin == null) throw PubkyAuthRequestError.InvalidPaymentDetails + if (!sharesUsdt) return bitcoin?.payload ?: byteArrayOf() + if ( + usdt != null && ( + usdt.methodId != MethodId.UsdtArbitrum || + PaykitUsdt.address(usdt.rawPayload) != usdt.value + ) + ) { + throw PubkyAuthRequestError.InvalidPaymentDetails + } + return buildJsonObject { + usdt?.let { put(MethodId.UsdtArbitrum.rawValue, Json.parseToJsonElement(it.rawPayload).jsonObject) } + if (sharesBitcoin) { + val account = checkNotNull(bitcoin).account + put( + "bitcoin_account", + buildJsonObject { + put("account_index", account.accountIndex) + put("address_type", account.addressType) + put("xpub", account.xpub) + } + ) + } + }.toString().encodeToByteArray() + } + companion object { + val USDT_ADDRESS_V1 = PubkyAuthClaim(Item.USDT_ADDRESS_V1) + val BITCOIN_AND_USDT = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1, Item.USDT_ADDRESS_V1) + /** Query parameter used for Bitkit-specific Pubky auth claims. */ const val QUERY_PARAMETER = "x-bitkit-claim" @@ -51,6 +93,7 @@ value class PubkyAuthClaim private constructor(val items: ImmutableList) { sealed class PubkyAuthRequestError(cause: Throwable? = null) : AppError(cause = cause) { class InvalidUrl(cause: Throwable) : PubkyAuthRequestError(cause) + data object InvalidPaymentDetails : PubkyAuthRequestError() data object RequesterChanged : PubkyAuthRequestError() data object MissingBitkitClaim : PubkyAuthRequestError() data object DuplicateBitkitClaim : PubkyAuthRequestError() diff --git a/app/src/main/java/to/bitkit/models/TrezorPublicKey.kt b/app/src/main/java/to/bitkit/models/TrezorPublicKey.kt new file mode 100644 index 0000000000..46b3166658 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/TrezorPublicKey.kt @@ -0,0 +1,32 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.TrezorPublicKeyResponse + +/** + * Account key to persist on a known device for [addressType]. + * + * Since bitkit-core 0.7.0 [TrezorPublicKeyResponse.xpub] is always a normalized xpub/tpub, while known + * devices paired on earlier versions stored the firmware's SLIP-132 form (ypub/zpub/upub/vpub) for + * BIP-49/BIP-84. Those strings feed [walletKey], entry matching and `deriveWalletId`, so they must stay + * byte-identical: SegWit types keep `xpubSegwit`, Legacy and Taproot keep `xpub` (Taproot `xpubSegwit` + * can be a descriptor). See bitkit-core `src/modules/trezor/README.md`, + * "Migrating to trezor-connect-rs 10.0.0 (Core 0.7.0)". + */ +fun TrezorPublicKeyResponse.storedAccountKey(addressType: AddressType): String = when (addressType) { + AddressType.P2SH, AddressType.P2WPKH -> xpubSegwit ?: xpub + else -> xpub +} + +/** Address type for the BIP purpose of [path] (e.g. `m/84'/1'/0'` is [AddressType.P2WPKH]), or null. */ +fun addressTypeForDerivationPath(path: String): AddressType? { + val segments = path.split("/") + if (segments.size < 2 || segments[0] != "m") return null + return when (segments[1].trimEnd('\'', 'h', 'H')) { + "44" -> AddressType.P2PKH + "49" -> AddressType.P2SH + "84" -> AddressType.P2WPKH + "86" -> AddressType.P2TR + else -> null + } +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 4512fdda2a..0e043fab7c 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import android.content.Context +import com.synonym.bitkitcore.UsdtException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview @@ -22,6 +23,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.Serializable @@ -114,6 +117,8 @@ class BackupRepo @Inject constructor( private val privatePaykitRepo: Provider, private val privatePaykitAddressReservationRepo: Provider, private val paykitPaymentProofRepo: Provider, + private val paykitUsdtPaymentRepo: Provider, + private val usdtRepo: Provider, private val paykitPaymentProofStore: PaykitPaymentProofStore, private val paykitPaymentRequestRepo: Provider, private val paykitPresentationStore: PaykitPaymentRequestPresentationStore, @@ -127,6 +132,7 @@ class BackupRepo @Inject constructor( private val clock: Clock, private val db: AppDb, ) { + private val walletBackupMutex = Mutex() private val scope = appScope(ioDispatcher, TAG) private val backupJobs = mutableMapOf() @@ -370,6 +376,15 @@ class BackupRepo @Inject constructor( dataListenerJobs.add(observeBackupChanges(paykitSdkService.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(paykitPaymentProofStore.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(onchainSendAttemptStore.backupStateVersion, BackupCategory.WALLET)) + dataListenerJobs.add( + observeBackupChanges(paykitUsdtPaymentRepo.get().backupStateVersion, BackupCategory.WALLET) + ) + dataListenerJobs.add( + observeBackupChanges( + usdtRepo.get().state.map { it.transfers }.distinctUntilChanged(), + BackupCategory.WALLET, + ) + ) dataListenerJobs.add(observeBackupChanges(paykitPresentationStore.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add( observeBackupChanges( @@ -521,12 +536,15 @@ class BackupRepo @Inject constructor( backupRequired = maxOf(backupRequired, it.required, it.synced + 1) it.copy(running = true, required = backupRequired) } - val data = runSuspendCatching { getBackupDataBytes(category) } - .getOrElse { - markBackupFailed(category, backupRequired, it) - return@withContext Result.failure(it) + + val upload = if (category == BackupCategory.WALLET) { + uploadWalletBackup(null) + } else { + runSuspendCatching { + vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow() } - vssBackupClient.putObject(key = category.name, data = data) + } + upload .onSuccess { withContext(NonCancellable) { cacheStore.updateBackupStatus(category) { @@ -548,6 +566,20 @@ class BackupRepo @Inject constructor( } } + suspend fun persistWalletBackup(usdt: String? = null): Result = + uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) } + + private suspend fun uploadWalletBackup(usdt: String?): Result = withContext(ioDispatcher) { + runSuspendCatching { + walletBackupMutex.withLock { + if (shouldSkipBackup()) throw UsdtException.BackupUnavailable() + vssBackupClient.setup().getOrThrow() + val data = getWalletBackupDataBytes(usdt) + vssBackupClient.putObject(key = BackupCategory.WALLET.name, data = data).getOrThrow() + } + }.map { } + } + private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) { withContext(NonCancellable) { cacheStore.updateBackupStatus(category) { @@ -646,7 +678,7 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } - private suspend fun getWalletBackupDataBytes(): ByteArray { + private suspend fun getWalletBackupDataBytes(usdt: String? = null): ByteArray { check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { "Wallet backup restore is incomplete" } @@ -673,8 +705,9 @@ class BackupRepo @Inject constructor( it.originalInputs == null && it.candidateTxids.isEmpty() } != true ) { "Waiting for signed receipt before wallet backup" } - check(privateRepo.backupStateVersion.value == privateVersion && - paykitSdkService.backupStateVersion.value == sdkVersion + check( + privateRepo.backupStateVersion.value == privateVersion && + paykitSdkService.backupStateVersion.value == sdkVersion ) { "Private payment state changed during wallet snapshot" } val proofs = snapshotProofs val active = snapshotAttempt?.let { attempt -> @@ -693,10 +726,12 @@ class BackupRepo @Inject constructor( paykitSdkBackupState = paykitSdkBackupState, watchOnlyAccounts = watchOnlyAccountSnapshot.accounts, watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState, + usdtWallet = usdt ?: usdtRepo.get().backupSnapshot(), paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), pendingProofs = proofs, activeOnchainAttempt = active, + usdt = paykitUsdtPaymentRepo.get().backupSnapshot(), acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), ), ) @@ -835,6 +870,7 @@ class BackupRepo @Inject constructor( keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes)) val parsed = json.decodeFromString(String(dataBytes)) var restoredAttempt: OnchainSendAttempt? = null + parsed.usdtWallet?.let { usdtRepo.get().restoreBackup(it).getOrThrow() } parsed.paykitPaymentState?.let { // Validate every proof before writing a guard or replacing existing payment state. it.pendingProofs.forEach { proof -> proof.restored() } @@ -859,6 +895,7 @@ class BackupRepo @Inject constructor( paykitPaymentRequestRepo.get().clear() paykitPresentationStore.restoreBackup(it.subscriptions) paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) + it.usdt?.let { usdt -> paykitUsdtPaymentRepo.get().restoreBackup(usdt) } paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty()) } db.transferDao().upsert(parsed.transfers) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index 19b9e789c2..1145ebfad9 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -88,6 +88,7 @@ class ContactPaymentSettingsRepo @Inject constructor( sharesPrivatePaykitEndpoints = canUsePrivateContactPayments, publicPaykitLightningEnabled = true, publicPaykitOnchainEnabled = true, + publicPaykitUsdtEnabled = true, ) } publicPaykitRepo.syncPublishedEndpoints(publish = true).getOrThrow() @@ -120,6 +121,7 @@ class ContactPaymentSettingsRepo @Inject constructor( sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints, publicPaykitLightningEnabled = previous.publicPaykitLightningEnabled, publicPaykitOnchainEnabled = previous.publicPaykitOnchainEnabled, + publicPaykitUsdtEnabled = previous.publicPaykitUsdtEnabled, ) } }.onFailure(error::addSuppressed) @@ -149,6 +151,7 @@ class ContactPaymentSettingsRepo @Inject constructor( sharesPrivatePaykitEndpoints = false, publicPaykitLightningEnabled = true, publicPaykitOnchainEnabled = true, + publicPaykitUsdtEnabled = true, ) } }.onFailure { diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index ff7b411259..da8e5a14d1 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -30,6 +30,7 @@ import to.bitkit.models.BTC_SCALE import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate +import to.bitkit.models.PaykitExchangeRate import to.bitkit.models.PrimaryDisplay import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.STUB_RATE @@ -275,6 +276,11 @@ data class CurrencyState( val lastSuccessfulRefresh: Long? = null, ) +val CurrencyState.paykitRate: PaykitExchangeRate? + get() = rates.firstOrNull { + it.quote == "USD" + }?.let { PaykitExchangeRate(it.lastPrice, it.lastUpdatedAt) } + interface AmountInputHandler { fun convertSatsToFiatString(sats: Long): String fun convertFiatToSats(fiat: Double): Long diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 1766aeee88..6878087a7c 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1,8 +1,9 @@ package to.bitkit.repositories -import dagger.Lazy import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PubkyIdentityCapability +import dagger.Lazy import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -22,9 +23,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection @@ -36,6 +35,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.PaykitPaymentStateBackup +import to.bitkit.models.PaykitRequestPayment import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.WalletScope import to.bitkit.services.PaykitSdkService @@ -46,6 +46,7 @@ import to.bitkit.utils.asNodeException import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -53,12 +54,13 @@ import kotlin.time.Instant @Serializable enum class PaykitPaymentProofKind(val type: String) { Lightning("bitcoin-bolt11-preimage"), - Onchain("bitcoin-onchain-txid"); + Onchain("bitcoin-onchain-txid"), + Usdt("erc20-transfer-eip712"); companion object { fun fromPaymentEndpointIdentifier(identifier: String): PaykitPaymentProofKind? { val method = MethodId.fromRawValue(identifier) ?: return null - return if (method.isOnchain) Onchain else Lightning + return if (method == MethodId.UsdtArbitrum) Usdt else if (method.isOnchain) Onchain else Lightning } } } @@ -79,6 +81,7 @@ data class PendingPaykitPaymentProof( val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, val proofData: String? = null, + val conversionQuoteId: String? = null, val billingPeriod: PaykitBillingPeriod? = null, val onchainAddress: String? = null, val onchainAmountSats: ULong? = null, @@ -175,13 +178,19 @@ class PaykitPaymentProofRepo @Inject constructor( paymentEndpointIdentifier: String, paymentAppId: String, kind: PaykitPaymentProofKind, + paymentTerms: PaykitRequestPayment? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { releaseInterruptedShopPreparation() val onchainAttempt = lightningRepo.currentOnchainSendAttempt() if (onchainAttempt?.requestId == request.id) throw PaykitPaymentRequestError.OperationInProgress operationMutex.withLock { - val proof = pendingProof(request, paymentEndpointIdentifier, paymentAppId, kind) + val proof = pendingProof( + request, + paymentEndpointIdentifier, + paymentAppId, + kind + ).copy(conversionQuoteId = paymentTerms?.quoteId) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) || @@ -251,6 +260,7 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun markOnchainPaymentStarted( request: PaykitPaymentRequest, address: String, + amountSats: ULong, walletId: String = WalletScope.default, privatePaymentListVersion: ULong? = null, signedTx: HwFundingSignedTx? = null, @@ -276,10 +286,13 @@ class PaykitPaymentProofRepo @Inject constructor( it.proofData == null } if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable + if (!proofs[index].copy(onchainAmountSats = amountSats).matchesOnchainPaymentAmount(request)) { + throw PaykitPaymentRequestError.RequestUnavailable + } proofs[index] = proofs[index].copy( paymentStarted = true, onchainAddress = address, - onchainAmountSats = request.amountSats, + onchainAmountSats = amountSats, onchainWalletId = walletId, privatePaymentListVersion = privatePaymentListVersion, previousPrivatePaymentListVersion = previousPrivatePaymentListVersion.takeIf { @@ -362,7 +375,12 @@ class PaykitPaymentProofRepo @Inject constructor( } /** Authorize a successor against the existing payer proof without preparing or consuming private context again. */ - suspend fun authorizeOnchainRecovery(attempt: OnchainSendAttempt): Result = withContext(ioDispatcher) { + suspend fun authorizeOnchainRecovery( + request: PaykitPaymentRequest, + attempt: OnchainSendAttempt + ): Result = withContext( + ioDispatcher + ) { runSuspendCatching { val current = lightningRepo.currentOnchainSendAttempt() operationMutex.withLock { @@ -381,9 +399,12 @@ class PaykitPaymentProofRepo @Inject constructor( val proof = loadProofs().singleOrNull { PubkyPublicKeyFormat.matches(it.identity, identity) && it.matchesOriginalShopAttempt(attempt) } ?: throw PaykitPaymentRequestError.RequestUnavailable + if (proof.requestId != request.id) throw PaykitPaymentRequestError.RequestUnavailable + val payment = proof.onchainPaymentTerms(request) if (proof.proofData != null || proof.onchainAcceptanceVerified) { throw PaykitPaymentRequestError.OperationInProgress } + request.submissionDeadline(payment) } } } @@ -397,12 +418,14 @@ class PaykitPaymentProofRepo @Inject constructor( completeOnchainPayment(request, txid, proof.paymentEndpointIdentifier, proof.paymentAppId) } + @Suppress("LongParameterList") suspend fun completeOnchainPayment( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, paymentAppId: String, acceptedOutcome: OnchainSendOutcome.Accepted? = null, + conversionQuoteId: String? = null, ): Boolean = withContext(ioDispatcher) { if (!txid.isHex(HASH_BYTE_COUNT)) { Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) @@ -474,7 +497,7 @@ class PaykitPaymentProofRepo @Inject constructor( onchainAcceptanceVerified = true, ) } else { - pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain).copy( + pendingOnchainProof(request, paymentEndpointIdentifier, paymentAppId, conversionQuoteId).copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), @@ -770,7 +793,9 @@ class PaykitPaymentProofRepo @Inject constructor( val original = proofs[index] if ((original.paymentIdentifier != null && !original.paymentIdentifier.equals(txid, true)) || (original.proofData != null && !original.proofData.equals(txid, true)) - ) return@runSuspendCatching false + ) { + return@runSuspendCatching false + } // This id identifies the original lookup; it is not yet a payment proof or acceptance evidence. val pending = original.copy(paymentIdentifier = txid.lowercase()) proofs[index] = pending @@ -1096,7 +1121,9 @@ class PaykitPaymentProofRepo @Inject constructor( val txid = proof.paymentIdentifier?.takeIf { it.isHex(HASH_BYTE_COUNT) } ?: return false if (!proof.paymentStarted || proof.onchainWalletId == WalletScope.default || (proof.proofData != null && !proof.proofData.equals(txid, true)) - ) return false + ) { + return false + } // A restored accepted proof can be newer than the independently uploaded activity backup. val address = proof.onchainAddress?.takeIf { it.isNotBlank() } ?: return false val amount = proof.onchainAmountSats ?: return false @@ -1176,6 +1203,10 @@ class PaykitPaymentProofRepo @Inject constructor( ?.publicKey ?.let(PubkyPublicKeyFormat::normalized) + suspend fun submit(proof: PendingPaykitPaymentProof): Result = withContext(ioDispatcher) { + operationMutex.withLock { runSuspendCatching { submitReady(proof) } } + } + private suspend fun submitReady(proof: PendingPaykitPaymentProof): Boolean { // Missing legacy app provenance must remain readable, never guessed for delivery. if (proof.paymentAppId.isBlank()) return false @@ -1194,12 +1225,7 @@ class PaykitPaymentProofRepo @Inject constructor( PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) } ?: return false val proofJson = proofJson(proof.kind, proofData) - val alreadyQueued = record.paymentProofs.any { - it.billingPeriod.matches(proof.billingPeriod) && - it.paymentEndpointIdentifier == proof.paymentEndpointIdentifier && - it.paymentAppId == proof.paymentAppId && - it.proof.exportText().proofValues() == proofJson.proofValues() - } + val alreadyQueued = record.paymentProofs.any { it.matchesSubmission(proof, proofJson) } if (!alreadyQueued) { paykitSdkService.submitPaymentProof( counterparty = proof.requestId.counterparty, @@ -1207,6 +1233,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentEndpointIdentifier = proof.paymentEndpointIdentifier, paymentAppId = proof.paymentAppId, proofJson = proofJson, + conversionQuoteId = proof.conversionQuoteId, billingPeriod = proof.billingPeriod, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) @@ -1219,11 +1246,13 @@ class PaykitPaymentProofRepo @Inject constructor( ) } } - runSuspendCatching { - removeProofsLocked { - PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId - } - }.onFailure { Logger.warn("Failed to clear a submitted Paykit payment proof", it, context = TAG) } + if (proof.kind != PaykitPaymentProofKind.Usdt) { + runSuspendCatching { + removeProofsLocked { + PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId + } + }.onFailure { Logger.warn("Failed to clear a submitted Paykit payment proof", it, context = TAG) } + } return true } @@ -1303,6 +1332,13 @@ class PaykitPaymentProofRepo @Inject constructor( return didPersist || !hasUndeliveredProof } + private suspend fun pendingOnchainProof( + request: PaykitPaymentRequest, + endpoint: String, + appId: String, + quoteId: String?, + ) = pendingProof(request, endpoint, appId, PaykitPaymentProofKind.Onchain).copy(conversionQuoteId = quoteId) + private suspend fun pendingProof( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, @@ -1358,6 +1394,7 @@ private fun endpointSupports(identifier: String, kind: PaykitPaymentProofKind): return when (kind) { PaykitPaymentProofKind.Lightning -> method == MethodId.Bolt11 || method == MethodId.Lnurl PaykitPaymentProofKind.Onchain -> method.isOnchain + PaykitPaymentProofKind.Usdt -> method == MethodId.UsdtArbitrum } } @@ -1377,19 +1414,15 @@ private fun PendingPaykitPaymentProof.isUnstartedFor( paymentIdentifier == null && proofData == null -private fun proofJson(kind: PaykitPaymentProofKind, data: String): String = buildJsonObject { - put("data", JsonPrimitive(data)) - put("type", JsonPrimitive(kind.type)) -}.toString() +private fun proofJson(kind: PaykitPaymentProofKind, data: String): String { + if (kind == PaykitPaymentProofKind.Usdt) return data + return buildJsonObject { + put("data", JsonPrimitive(data)) + put("type", JsonPrimitive(kind.type)) + }.toString() +} -private fun String.proofValues(): JsonObject? = runCatching { - Json.parseToJsonElement(this).jsonObject.let { values -> - buildJsonObject { - values["data"]?.jsonPrimitive?.contentOrNull?.let { put("data", JsonPrimitive(it)) } - values["type"]?.jsonPrimitive?.contentOrNull?.let { put("type", JsonPrimitive(it)) } - } - } -}.getOrNull() +private fun String.proofValues(): JsonObject? = runCatching { Json.parseToJsonElement(this).jsonObject }.getOrNull() private fun String.matchesPaymentHash(paymentHash: String): Boolean { val preimage = hexBytes() ?: return false @@ -1421,5 +1454,24 @@ internal fun PendingPaykitPaymentProof.isReopenableHardwareReceipt(identity: Str retainedSignedHardwareReceipt() != null internal fun PendingPaykitPaymentProof.matchesHardwareRetryRequest(request: PaykitPaymentRequest): Boolean = - requestId == request.id && billingPeriod == request.billingPeriod && onchainAmountSats == request.amountSats && + requestId == request.id && billingPeriod == request.billingPeriod && matchesOnchainPaymentAmount(request) && paymentEndpointIdentifier in request.acceptedPaymentEndpointIdentifiers + +private fun PendingPaykitPaymentProof.matchesOnchainPaymentAmount(request: PaykitPaymentRequest): Boolean = + runCatching { onchainPaymentTerms(request) }.isSuccess + +internal fun PendingPaykitPaymentProof.onchainPaymentTerms(request: PaykitPaymentRequest): PaykitRequestPayment { + val method = MethodId.entries.firstOrNull { it.rawValue == paymentEndpointIdentifier && it.isOnchain } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val now = Clock.System.now() + val payment = request.payment(method, now, conversionQuoteId) + if (!payment.isValid(now) || payment.amount.atomic != onchainAmountSats) { + throw PaykitPaymentRequestError.RequestUnavailable + } + return payment +} + +private fun PaymentProofRecord.matchesSubmission(proof: PendingPaykitPaymentProof, proofJson: String): Boolean = + billingPeriod.matches(proof.billingPeriod) && + paymentEndpointIdentifier == proof.paymentEndpointIdentifier && paymentAppId == proof.paymentAppId && + conversionQuoteId == proof.conversionQuoteId && this.proof.exportText().proofValues() == proofJson.proofValues() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 632b81a791..45c123caf9 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -7,6 +7,7 @@ import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole @@ -50,6 +51,10 @@ import to.bitkit.di.SubscriptionClock import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPayment +import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms @@ -82,8 +87,9 @@ data class PaykitPaymentRequestId( data class PaykitPaymentRequest( val paymentRequestId: String, val counterparty: String, - val amountValue: String, - val amountSats: ULong, + val amount: PaykitAmount, + val paymentReference: String, + val pricing: PaykitRequestPricing = PaykitRequestPricing(), val note: String? = null, val createdAt: Instant? = null, val expiresAt: Instant?, @@ -93,10 +99,19 @@ data class PaykitPaymentRequest( val lifecycleState: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, val billingPeriod: PaykitBillingPeriod? = null, val paymentProofKind: PaykitPaymentProofKind? = null, + val paymentProofEventId: String? = null, val paymentDeadlineAt: Instant? = null, val isPaymentInFlight: Boolean = false, val proposalStreamItemId: ULong? = null, ) { + val amountValue: String get() = amount.value + + fun payment(method: MethodId, at: Instant, quoteId: String? = null): PaykitRequestPayment = + pricing.payment(amount, method.rawValue, billingPeriod, at, quoteId) + + fun submissionDeadline(payment: PaykitRequestPayment?): Instant? = + listOfNotNull(paymentDeadlineAt, payment?.expiresAt).minOrNull() + enum class ParseFailure( val logValue: String, val shouldLogIncomingRejection: Boolean = true, @@ -109,7 +124,6 @@ data class PaykitPaymentRequest( RecurringRequest("recurring_request", shouldLogIncomingRejection = false), UnsupportedRecurrence("unsupported_recurrence"), UnsupportedAsset("unsupported_asset"), - UnsupportedPricing("unsupported_pricing"), UnsupportedPaymentDeadline("unsupported_payment_deadline"), InvalidAmount("invalid_amount"), AmountOutOfRange("amount_out_of_range"), @@ -138,13 +152,30 @@ data class PaykitPaymentRequest( fun isExpired(now: Instant): Boolean = isProposalExpired(now) || isPaymentDeadlineExpired(now) - fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean = - amountMsats == null || amountMsats == satsToMsat(amountSats) + fun acceptsLightningInvoiceAmountMsats( + amountMsats: ULong?, + paymentTerms: PaykitRequestPayment? = null, + at: Instant = Clock.System.now(), + ): Boolean { + if (amountMsats == null) return true + val payment = runCatching { payment(MethodId.Bolt11, at, paymentTerms?.quoteId) }.getOrNull() ?: return false + return payment.isValid(at) && (paymentTerms == null || paymentTerms == payment) && + payment.amount.atomic <= ULong.MAX_VALUE / 1000uL && amountMsats == satsToMsat(payment.amount.atomic) + } fun acceptsLightningInvoiceAmountSats(amountSats: ULong): Boolean = - amountSats == 0uL || acceptsPaymentAmount(amountSats) + amountSats == 0uL || acceptsPaymentAmount(amountSats, MethodId.Bolt11) - fun acceptsPaymentAmount(amountSats: ULong): Boolean = amountSats == this.amountSats + fun acceptsPaymentAmount( + amountSats: ULong, + method: MethodId, + paymentTerms: PaykitRequestPayment? = null, + at: Instant = Clock.System.now(), + ): Boolean { + val payment = runCatching { payment(method, at, paymentTerms?.quoteId) }.getOrNull() ?: return false + return payment.isValid(at) && (paymentTerms == null || paymentTerms == payment) && + amountSats == payment.amount.atomic + } fun belongsTo(subscription: PaykitSubscription): Boolean = billingPeriod != null && @@ -204,10 +235,11 @@ private fun String.redactedForPaymentRequestDiagnostics(): String = private data class ParsedPaykitPaymentRequestTerms( val terms: PaymentRequestTerms, - val amountSats: ULong, + val amount: PaykitAmount, val endpoints: List, val expiresAt: Instant?, val paymentDeadlineAt: Instant?, + val convertedToBitcoin: Boolean = false, ) enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } @@ -219,9 +251,10 @@ data class PaykitPaymentRequestTarget( ) data class PaykitPaymentRequestDraft( - val amountSats: ULong, + val amount: PaykitAmount, val note: String, val expiresAt: Instant, + val acceptedPaymentEndpointIdentifiers: List? = null, ) data class PaykitPaymentRequestCreation( @@ -231,7 +264,7 @@ data class PaykitPaymentRequestCreation( ) class PaykitSubscriptionDraft( - val amountSats: ULong, + val amount: PaykitAmount, val name: String, val description: String, val frequency: PaykitRecurrenceUnit, @@ -284,9 +317,13 @@ class PaykitPaymentRequestRepo @Inject constructor( private val diagnostics: PaykitPaymentRequestDiagnostics, private val paymentProofStore: PaykitPaymentProofStore, private val paymentProofRepo: PaykitPaymentProofRepo, + private val usdtPayments: PaykitUsdtPaymentRepo, private val subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler, private val clock: Clock, @SubscriptionClock private val subscriptionClock: Clock, + private val lightningRepo: LightningRepo, + private val currencyRepo: CurrencyRepo, + private val usdtRepo: UsdtRepo, ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" @@ -301,6 +338,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private val creationMutex = Mutex() private val processingLock = Any() private val processingRequestIds = mutableSetOf() + private val processingSubscriptionIds = mutableSetOf() private val stateGeneration = AtomicLong() private val completedRefreshVersion = AtomicLong() private var completedRefreshGeneration = -1L @@ -684,7 +722,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val generation = stateGeneration.get() val expectedIdentity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val proposalDate = clock.now() - if (draft.amountSats == 0uL || !isAvailable()) throw PaykitPaymentRequestError.RequestUnavailable + if (draft.amount.atomic == 0uL || !isAvailable()) throw PaykitPaymentRequestError.RequestUnavailable if (draft.expiresAt <= proposalDate) throw PaykitPaymentRequestError.RequestExpired val selectedSavedKey = savedPublicKeys.firstOrNull { @@ -696,16 +734,16 @@ class PaykitPaymentRequestRepo @Inject constructor( if (target !in targets) throw PaykitPaymentRequestError.RequestUnavailable val settings = settingsStore.data.first() if (!settings.sharesPrivatePaykitEndpoints) throw PaykitPaymentRequestError.RequestUnavailable - val endpointIdentifiers = acceptedPaymentEndpointIdentifiers(settings) + val endpointIdentifiers = acceptedPaymentEndpointIdentifiers(settings).filter { + draft.acceptedPaymentEndpointIdentifiers?.contains(it) != false + } if (endpointIdentifiers.isEmpty()) throw PaykitPaymentRequestError.RequestUnavailable val note = draft.note.trim().take(256) - val proposal = PaykitPaymentRequestProposalTerms( - amountValue = draft.amountSats.toBitcoinAmount(), - paymentReference = "bitkit-${UUID.randomUUID()}", - proposalExpiresAt = draft.expiresAt.toString(), - acceptedPaymentEndpointIdentifiers = endpointIdentifiers, - metadataJson = JsonObject(mapOf("note" to JsonPrimitive(note))).toString(), + val proposal = buildPaymentRequestProposal( + draft.copy(note = note), + endpointIdentifiers, + proposalDate ) actionVersion++ val record = paykitSdkService.proposePaymentRequest( @@ -733,6 +771,29 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + private fun buildPaymentRequestProposal( + draft: PaykitPaymentRequestDraft, + endpointIdentifiers: List, + proposalDate: Instant, + ): PaykitPaymentRequestProposalTerms { + val rates = PaykitRequestPricing.rates( + draft.amount.asset, + endpointIdentifiers, + currencyRepo.currencyState.value.paykitRate, + proposalDate + ) + return PaykitPaymentRequestProposalTerms( + amountValue = draft.amount.value, + amountAsset = draft.amount.asset.code, + conversion = rates.takeIf { it.isNotEmpty() }?.let(PaymentConversion::Fixed), + paymentDeadline = PaymentDeadline.At(draft.expiresAt.toString()), + paymentReference = "bitkit-${UUID.randomUUID()}", + proposalExpiresAt = draft.expiresAt.toString(), + acceptedPaymentEndpointIdentifiers = endpointIdentifiers, + metadataJson = JsonObject(mapOf("note" to JsonPrimitive(draft.note))).toString(), + ) + } + suspend fun proposeSubscription( draft: PaykitSubscriptionDraft, target: PaykitPaymentRequestTarget, @@ -766,7 +827,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val description = draft.description.trim() validateSubscriptionDraft(draft, name, validationDate) - val endpoints = subscriptionProposalEndpoints(target, savedPublicKeys, expectedIdentity) + val endpoints = subscriptionProposalEndpoints(draft.amount.asset, target, savedPublicKeys, expectedIdentity) val reservedIconUri = PaykitSubscriptionProposal.reservedIconUri.takeIf { draft.iconBytes != null } val preflight = buildSubscriptionProposal(draft, name, description, reservedIconUri, endpoints, clock.now()) PaykitSubscriptionProposal.validate(preflight) @@ -804,7 +865,7 @@ class PaykitPaymentRequestRepo @Inject constructor( name: String, proposalDate: Instant, ) { - val hasValidContent = draft.amountSats != 0uL && name.isNotEmpty() + val hasValidContent = draft.amount.atomic != 0uL && name.isNotEmpty() val canPropose = draft.frequency.isSupported && isAvailable() if (!hasValidContent || !canPropose) { throw PaykitPaymentRequestError.RequestUnavailable @@ -817,6 +878,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private suspend fun subscriptionProposalEndpoints( + asset: PaykitAsset, target: PaykitPaymentRequestTarget, savedPublicKeys: List, expectedIdentity: String, @@ -829,7 +891,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } .orEmpty() val settings = settingsStore.data.first() - val endpoints = acceptedPaymentEndpointIdentifiers(settings) + val endpoints = PaykitRequestPricing.subscriptionEndpoints(asset, acceptedPaymentEndpointIdentifiers(settings)) if (target !in targets || !settings.sharesPrivatePaykitEndpoints || endpoints.isEmpty()) { throw PaykitPaymentRequestError.RequestUnavailable } @@ -850,9 +912,12 @@ class PaykitPaymentRequestRepo @Inject constructor( "benefits" to JsonArray(emptyList()), ) iconUri?.let { subscriptionMetadata["icon_uri"] = JsonPrimitive(it) } + val rates = PaykitRequestPricing.rates(draft.amount.asset, endpointIdentifiers, null, startsAt) val timestamp = Instant.fromEpochSeconds(startsAt.epochSeconds).toString() return PaykitPaymentRequestProposalTerms( - amountValue = draft.amountSats.toBitcoinAmount(), + amountValue = draft.amount.value, + amountAsset = draft.amount.asset.code, + conversion = rates.takeIf { it.isNotEmpty() }?.let(PaymentConversion::Fixed), paymentReference = "bitkit-${UUID.randomUUID()}", proposalExpiresAt = draft.expiresAt.toString(), acceptedPaymentEndpointIdentifiers = endpointIdentifiers, @@ -1100,7 +1165,11 @@ class PaykitPaymentRequestRepo @Inject constructor( identity = identity, subscriptionId = it.id, ).getOrThrow() - if (protectedRequestIds.isNotEmpty()) { + val protectedUsdtRequestIds = usdtPayments.protectedRequestIdsForSubscriptionCancellation( + identity = identity, + subscriptionId = it.id, + ).getOrThrow() + if (protectedRequestIds.isNotEmpty() || protectedUsdtRequestIds.isNotEmpty()) { throw PaykitPaymentRequestError.OperationInProgress } } @@ -1194,7 +1263,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val reopenableHardwareProofs = expectedIdentity?.let { identity -> paymentProofStore.load().filter { it.isReopenableHardwareReceipt(identity) } }.orEmpty() - val allSubscriptions = records.mapNotNull(PaymentRequestRecord::toPaykitSubscription) + val receipts = expectedIdentity?.let { usdtPayments.verifiedReceipts(it).getOrThrow() }.orEmpty() + val allSubscriptions = records.mapNotNull { it.toPaykitSubscription(verifiedUsdtReceipts = receipts) } .map { it.withExpiredLifecycle(subscriptionNow) } val blockedSubscriptionIds = allSubscriptions.mapNotNull { subscription -> subscription.id.takeIf { @@ -1237,7 +1307,7 @@ class PaykitPaymentRequestRepo @Inject constructor( .mapTo(mutableSetOf()) { it.id } val updatedDismissedPaymentIds = dismissedSubscriptionPaymentIds.intersect(activeRecurringRequestIds) val dueRequests = recurringRequestsBySubscription - .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && !it.hasPaymentDeadline } + .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } .values .flatten() .filter { @@ -1416,8 +1486,19 @@ class PaykitPaymentRequestRepo @Inject constructor( }.awaitAll() } - private fun acceptedPaymentEndpointIdentifiers(settings: SettingsData): List = buildList { - if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) add(MethodId.Bolt11.rawValue) + fun acceptedPaymentEndpointIdentifiers(settings: SettingsData): List = buildList { + if (PublicPaykitRepo.isUsdtPaymentOptionEnabled( + settings, + usdtRepo.state.value + ) + ) { + add(MethodId.UsdtArbitrum.rawValue) + } + if (lightningRepo.canReceive() && PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) { + add( + MethodId.Bolt11.rawValue + ) + } if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { addAll(MethodId.entries.filter { it.isOnchain }.map(MethodId::rawValue)) } @@ -1434,6 +1515,12 @@ class PaykitPaymentRequestRepo @Inject constructor( activatedGeneration == stateGeneration.get() && request.id in acceptedOneTimeRequestIds private fun hasCurrentExecutionContext(request: PaykitPaymentRequest): Boolean { + if (synchronized(processingLock) { + PaykitSubscriptionId(request.paymentRequestId, request.counterparty) in processingSubscriptionIds + } + ) { + return false + } if (request.billingPeriod == null) { return hasLocalAcceptance(request) && _paymentRequestHistory.value.any { it.id == request.id && it.lifecycleState == PaymentRequestLifecycleState.ACCEPTED @@ -1506,15 +1593,21 @@ class PaykitPaymentRequestRepo @Inject constructor( operation: suspend (PaykitSubscription) -> PaymentRequestRecord, ): Result = withContext(ioDispatcher) { runSuspendCatching { - operationMutex.withLock { - val current = _subscriptions.value.firstOrNull { it.id == subscription.id } - ?: throw PaykitPaymentRequestError.RequestUnavailable - actionVersion++ - val record = operation(current) - processPendingMessages() - val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - applySubscriptionRecordLocked(record, subscriptionClock.now()) - synchronizeAfterSubscriptionAction(identity) + val isNewAction = synchronized(processingLock) { processingSubscriptionIds.add(subscription.id) } + if (!isNewAction) throw PaykitPaymentRequestError.OperationInProgress + try { + operationMutex.withLock { + val current = _subscriptions.value.firstOrNull { it.id == subscription.id } + ?: throw PaykitPaymentRequestError.RequestUnavailable + actionVersion++ + val record = operation(current) + processPendingMessages() + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + applySubscriptionRecordLocked(record, subscriptionClock.now()) + synchronizeAfterSubscriptionAction(identity) + } + } finally { + synchronized(processingLock) { processingSubscriptionIds.remove(subscription.id) } } }.onFailure { Logger.warn("Failed to update Paykit subscription", it, context = TAG) } } @@ -1531,7 +1624,9 @@ class PaykitPaymentRequestRepo @Inject constructor( } private suspend fun applySubscriptionRecordLocked(record: PaymentRequestRecord, now: Instant) { - val subscription = record.toPaykitSubscription()?.withExpiredLifecycle(now) ?: return + val receipts = activeIdentity?.let { usdtPayments.verifiedReceipts(it).getOrThrow() }.orEmpty() + val subscription = record.toPaykitSubscription(verifiedUsdtReceipts = receipts) + ?.withExpiredLifecycle(now) ?: return _subscriptions.update { subscriptions -> subscriptions.filterNot { it.id == subscription.id } + subscription } @@ -1551,8 +1646,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val recurringRequests = requestsThroughAcceptance(subscription, now) val unpaidRequests = if ( - subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && - !subscription.hasPaymentDeadline + subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING ) { recurringRequests.filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED } } else { @@ -1823,26 +1917,24 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest( if (requiresActionableRequest && paymentDeadlineAt?.let { now > it } == true) { return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.Expired) } - var endpoints = PaykitIssuerInterop.supportedEndpointIdentifiers( + val supportedEndpoints = PaykitIssuerInterop.supportedEndpointIdentifiers( requestTerms.acceptedPaymentEndpointIdentifiers, network, ) - val amountSats: ULong - if (requestTerms.conversion != null) { - val payment = PaykitBitcoinRequestPricing.bitcoinPayment(requestTerms, endpoints) - ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing) - amountSats = payment.amountSats - endpoints = payment.endpointIdentifiers + val asset = PaykitAsset.entries.firstOrNull { it.code == requestTerms.amount.asset } + val bitcoinPayment = if (asset == null) { + PaykitBitcoinRequestPricing.bitcoinPayment(requestTerms, supportedEndpoints.filter { it.startsWith("btc-") }) + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset) } else { - if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) { - return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset) - } - amountSats = requestTerms.amount.value.toPaykitSats() - ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount) - if (amountSats > ULong.MAX_VALUE / 1000uL) { - return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.AmountOutOfRange) - } + null } + val amount = bitcoinPayment?.let { PaykitAmount(PaykitAsset.BTC, it.amountSats) } + ?: runCatching { PaykitAmount.parse(requireNotNull(asset), requestTerms.amount.value) }.getOrNull() + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount) + if (amount.asset == PaykitAsset.BTC && amount.atomic > ULong.MAX_VALUE / 1000uL) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.AmountOutOfRange) + } + val endpoints = bitcoinPayment?.endpointIdentifiers ?: supportedEndpoints if (requiresActionableRequest && endpoints.isEmpty()) { return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.NoSupportedEndpoint) } @@ -1856,7 +1948,14 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest( return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.Expired) } - val parsedTerms = ParsedPaykitPaymentRequestTerms(requestTerms, amountSats, endpoints, expiresAt, paymentDeadlineAt) + val parsedTerms = ParsedPaykitPaymentRequestTerms( + requestTerms, + amount, + endpoints, + expiresAt, + paymentDeadlineAt, + bitcoinPayment != null, + ) return PaykitPaymentRequestParseResult.Parsed(toPaykitPaymentRequest(expectedRole, parsedTerms, now)) } @@ -1868,8 +1967,13 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( paymentRequestId = paymentRequestId, proposalStreamItemId = proposalStreamItemId, counterparty = counterparty, - amountValue = parsedTerms.terms.amount.value, - amountSats = parsedTerms.amountSats, + amount = parsedTerms.amount, + paymentReference = parsedTerms.terms.paymentReference.exportText(), + pricing = PaykitRequestPricing( + parsedTerms.terms.conversion.takeUnless { parsedTerms.convertedToBitcoin }, + parsedTerms.terms.paymentDeadline, + conversionQuotes, + ), note = parsedTerms.terms.metadata.note(), createdAt = lastEventAt?.let { runCatching { Instant.parse(it) }.getOrNull() }, expiresAt = parsedTerms.expiresAt, @@ -1896,12 +2000,13 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( } else { state }, + paymentProofEventId = paymentProofs.lastOrNull()?.eventId, paymentProofKind = paymentProofs.lastOrNull()?.let { PaykitPaymentProofKind.fromPaymentEndpointIdentifier(it.paymentEndpointIdentifier) }, ) -private fun PaymentRequestRecord.toPaykitPaymentRequestHistory(now: Instant): PaykitPaymentRequest? { +internal fun PaymentRequestRecord.toPaykitPaymentRequestHistory(now: Instant): PaykitPaymentRequest? { val role = localRole ?: return null if (role == PaymentRequestLocalRole.UNKNOWN) return null return when (val result = parsePaykitPaymentRequest(role, now, requiresActionableRequest = false)) { @@ -1920,8 +2025,10 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( return PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = target.publicKey, - amountValue = draft.amountSats.toBitcoinAmount(), - amountSats = draft.amountSats, + amount = draft.amount, + paymentReference = terms?.paymentReference?.exportText().orEmpty(), + pricing = PaykitRequestPricing(terms?.conversion, terms?.paymentDeadline, conversionQuotes), + paymentDeadlineAt = PaykitRequestPricing(deadline = terms?.paymentDeadline).paymentDeadline(null), note = draft.note.takeIf(String::isNotBlank), createdAt = lastEventAt?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: createdAt, expiresAt = draft.expiresAt, @@ -1945,9 +2052,6 @@ internal fun PrivateJsonObject.note(): String? = runCatching { ?.takeIf(String::isNotEmpty) }.getOrNull() -private fun ULong.toBitcoinAmount(): String = - BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString() - internal fun String.toPaykitSats(): ULong? { if (!bitcoinAmountPattern.matches(this)) return null return runCatching { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 83a5d33b2b..5cbc4ff00e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -15,6 +15,9 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import java.time.ZoneOffset import java.time.ZonedDateTime import kotlin.time.ExperimentalTime @@ -173,8 +176,9 @@ data class PaykitSubscriptionId( data class PaykitSubscription( val paymentRequestId: String, val counterparty: String, - val amountValue: String, - val amountSats: ULong, + val amount: PaykitAmount, + val paymentReference: String, + val pricing: PaykitRequestPricing, val note: String?, val createdAt: Instant?, val proposalExpiresAt: Instant?, @@ -186,9 +190,12 @@ data class PaykitSubscription( val lifecycleState: PaymentRequestLifecycleState, val paidPeriods: List, val paymentProofKinds: Map = emptyMap(), - val hasPaymentDeadline: Boolean = false, + val paymentProofEventIds: Map = emptyMap(), + val submittedPeriods: List = paidPeriods, val proposalStreamItemId: ULong? = null, ) { + val amountValue: String get() = amount.value + val id: PaykitSubscriptionId get() = PaykitSubscriptionId(paymentRequestId, counterparty) @@ -205,7 +212,6 @@ data class PaykitSubscription( fun isProposalActionable(now: Instant): Boolean = isProposalVisible(now) && - !hasPaymentDeadline && recurrence.unit.isSupported && recurrence.canMaterializePeriods && acceptedPaymentEndpointIdentifiers.isNotEmpty() @@ -244,8 +250,9 @@ data class PaykitSubscription( paymentRequestId = paymentRequestId, proposalStreamItemId = proposalStreamItemId, counterparty = counterparty, - amountValue = amountValue, - amountSats = amountSats, + amount = amount, + paymentReference = paymentReference, + pricing = pricing, note = note, createdAt = period.startsAt, expiresAt = null, @@ -256,24 +263,27 @@ data class PaykitSubscription( PaymentRequestLifecycleState.ACTIVE_RECURRING }, billingPeriod = period, + paymentDeadlineAt = pricing.paymentDeadline(period), paymentProofKind = paymentProofKinds[period], + paymentProofEventId = paymentProofEventIds[period], ) } } /** [now] is the subscription clock; [acceptedAt] is when accepting will store the acceptance, in real time. */ fun paymentDueOnAcceptance(now: Instant, acceptedAt: Instant = now): PaykitPaymentRequest? = - if (hasPaymentDeadline) null else requestsThrough(now, acceptedAt).firstOrNull() + requestsThrough(now, acceptedAt).firstOrNull() fun receivedPaymentRequests(): List { if (!isCreatedByUser) return emptyList() - return paidPeriods.map { period -> + return submittedPeriods.map { period -> PaykitPaymentRequest( paymentRequestId = paymentRequestId, proposalStreamItemId = proposalStreamItemId, counterparty = counterparty, - amountValue = amountValue, - amountSats = amountSats, + amount = amount, + paymentReference = paymentReference, + pricing = pricing, note = note, createdAt = period.startsAt, expiresAt = null, @@ -281,7 +291,9 @@ data class PaykitSubscription( direction = PaykitPaymentRequestDirection.Outgoing, lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, billingPeriod = period, + paymentDeadlineAt = pricing.paymentDeadline(period), paymentProofKind = paymentProofKinds[period], + paymentProofEventId = paymentProofEventIds[period], ) } } @@ -298,13 +310,12 @@ fun PaykitSubscription.runsUntilPaidThrough(now: Instant): Boolean = @Suppress("CyclomaticComplexMethod", "ReturnCount") internal fun PaymentRequestRecord.toPaykitSubscription( deliveryStatusOverride: PaykitPaymentRequestDeliveryStatus? = null, + verifiedUsdtReceipts: List = emptyList(), ): PaykitSubscription? { val role = localRole.toSubscriptionRole() ?: return null val requestTerms = terms ?: return null - if (requestTerms.conversion != null) return null val sdkRecurrence = requestTerms.recurrence ?: return null if ( - requestTerms.amount.asset != "btc" || sdkRecurrence.every == 0u || sdkRecurrence.every > Int.MAX_VALUE.toUInt() ) { @@ -318,9 +329,10 @@ internal fun PaymentRequestRecord.toPaykitSubscription( val proposalExpiresAt = requestTerms.proposalExpiresAt?.parseInstant() ?: if (requestTerms.proposalExpiresAt == null) null else return null if (recurrenceEndsAt != null && recurrenceEndsAt <= startsAt) return null - val amountSats = requestTerms.amount.value.toPaykitSats() - ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } - ?: return null + val asset = PaykitAsset.entries.firstOrNull { it.code == requestTerms.amount.asset } ?: return null + val amount = runCatching { PaykitAmount.parse(asset, requestTerms.amount.value) }.getOrNull() ?: return null + if (asset == PaykitAsset.BTC && amount.atomic > ULong.MAX_VALUE / 1000uL) return null + val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers .filter { MethodId.fromRawValue(it) != null } .distinct() @@ -337,8 +349,9 @@ internal fun PaymentRequestRecord.toPaykitSubscription( paymentRequestId = paymentRequestId, proposalStreamItemId = proposalStreamItemId, counterparty = counterparty, - amountValue = requestTerms.amount.value, - amountSats = amountSats, + amount = amount, + paymentReference = requestTerms.paymentReference.exportText(), + pricing = PaykitRequestPricing(requestTerms.conversion, requestTerms.paymentDeadline, conversionQuotes), note = requestTerms.metadata.note()?.take(256), createdAt = lastEventAt?.parseInstant(), proposalExpiresAt = proposalExpiresAt, @@ -348,12 +361,29 @@ internal fun PaymentRequestRecord.toPaykitSubscription( role = role, deliveryStatus = subscriptionDeliveryStatus(role, deliveryStatusOverride), lifecycleState = state, - paidPeriods = payments.map { it.first }.distinct(), + paidPeriods = paidSubscriptionPeriods(payments, role, verifiedUsdtReceipts), + submittedPeriods = payments.map { it.first }.distinct(), paymentProofKinds = payments.mapNotNull { (period, kind) -> kind?.let { period to it } }.toMap(), - hasPaymentDeadline = requestTerms.paymentDeadline != null, + paymentProofEventIds = paymentProofs.mapNotNull { proof -> + proof.toSubscriptionPayment(recurrence)?.first?.let { it to proof.eventId } + }.toMap(), + ) } +private fun PaymentRequestRecord.paidSubscriptionPeriods( + payments: List>, + role: PaykitSubscriptionRole, + receipts: List, +): List = payments.filter { (period, kind) -> + role == PaykitSubscriptionRole.Payer || kind != PaykitPaymentProofKind.Usdt || + receipts.any { receipt -> + receipt.satisfied && receipt.requestId == PaykitPaymentRequestId( + paymentRequestId, counterparty, period.startsAt.toString(), + ) && paymentProofs.any { it.eventId == receipt.proofEventId } + } +}.map { it.first }.distinct() + @Suppress("ReturnCount") private fun PaymentProofRecord.toSubscriptionPayment( recurrence: PaykitSubscriptionRecurrence, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index ee990ca5dc..d172709a73 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -88,7 +88,6 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( val scheduledWork = subscriptions .filter { it.isPayer && - !it.hasPaymentDeadline && it.recurrence.unit.isSupported && acceptedAt(it) != null } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt index f22fa3ed14..27bf2d377b 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt @@ -1,23 +1,24 @@ package to.bitkit.repositories +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentDeadline import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import to.bitkit.services.PaykitPaymentRequestProposalTerms +import to.bitkit.services.PaykitSdkService internal object PaykitSubscriptionProposal { - /** Maximum plaintext size accepted by Paykit's pubky-noise transport. */ - const val MAX_MESSAGE_BYTES = 1000 - /** Longest JPEG avatar URI produced by Bitkit's staging profile namespace. */ val reservedIconUri = "pubky://" + "x".repeat(122) fun validate(terms: PaykitPaymentRequestProposalTerms) { - if (encodedSize(terms) > MAX_MESSAGE_BYTES) throw PaykitPaymentRequestError.SubscriptionTooLong + if (encodedSize(terms) > PaykitSdkService.MAX_MESSAGE_BYTES) throw PaykitPaymentRequestError.SubscriptionTooLong } fun encodedSize(terms: PaykitPaymentRequestProposalTerms): Int { @@ -32,7 +33,7 @@ internal object PaykitSubscriptionProposal { putJsonObject("request") { putJsonObject("amount") { put("value", terms.amountValue) - put("asset", "btc") + put("asset", terms.amountAsset) } put("payment_reference", terms.paymentReference) put("proposal_expires_at", terms.proposalExpiresAt) @@ -47,9 +48,46 @@ internal object PaykitSubscriptionProposal { terms.acceptedPaymentEndpointIdentifiers.forEach { add(JsonPrimitive(it)) } } put("required_app_id", "bitkit") + putConversion(terms.conversion) + terms.paymentDeadline?.let { deadline -> + putJsonObject("payment_deadline") { + when (deadline) { + is PaymentDeadline.At -> { + put("type", "at") + put("timestamp", deadline.timestamp) + } + is PaymentDeadline.PeriodStart -> { + put("type", "period_start") + put("seconds", Json.parseToJsonElement(deadline.seconds.toString())) + } + } + } + } put("metadata", Json.parseToJsonElement(terms.metadataJson)) } } return wire.toString().encodeToByteArray().size } + private fun JsonObjectBuilder.putConversion(conversion: PaymentConversion?) { + conversion?.let { conversion -> + putJsonObject("conversion") { + when (conversion) { + is PaymentConversion.Fixed -> { + put("type", "fixed") + putJsonArray("rates") { + conversion.rates.forEach { rate -> + add( + buildJsonObject { + put("asset", rate.asset) + put("value", rate.value) + } + ) + } + } + } + PaymentConversion.PerPeriod -> put("type", "per_period") + } + } + } + } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt new file mode 100644 index 0000000000..e820ed6ec2 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt @@ -0,0 +1,692 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentProof +import com.synonym.bitkitcore.UsdtPaymentProofBinding +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.PaykitPublicKeys +import com.synonym.paykit.PaymentProofRecord +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PubkyIdentityCapability +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPayment +import to.bitkit.models.PaykitUsdt +import to.bitkit.models.PaykitUsdtStateBackup +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.services.PaykitSdkOperationLock.Priority +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.Logger +import to.bitkit.viewmodels.ContactPaymentContext +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Serializable +data class PaykitUsdtProof( + val type: String, + @SerialName("chain_id") val chainId: String, + @SerialName("transaction_hash") val transactionHash: String, + @SerialName("receipt_log_index") val receiptLogIndex: String, + val signature: String, +) { + constructor(value: UsdtPaymentProof) : this( + "erc20-transfer-eip712", + value.chainId, + value.transactionHash, + value.receiptLogIndex, + value.signature + ) + val coreValue: UsdtPaymentProof get() = UsdtPaymentProof(chainId, transactionHash, receiptLogIndex, signature) +} + +@Serializable +data class PaykitUsdtAttempt( + val quoteId: String, + val wallet: String, + val identity: String, + val contact: String, + val requestId: PaykitPaymentRequestId? = null, + val binding: UsdtPaymentProofBinding? = null, + val billingPeriod: PaykitBillingPeriod? = null, + val proof: PaykitUsdtProof? = null, + val proofQueued: Boolean = false, + val paymentStarted: Boolean = false, +) + +@Serializable +data class PaykitUsdtReceipt( + val wallet: String, + val identity: String, + val requestId: PaykitPaymentRequestId, + val paymentId: String, + val proofEventId: String, + val verified: Boolean, + val transferId: String, + val amount: PaykitAmount, + val receivedAtMillis: Long, + val underpaid: Boolean, + val afterExpiry: Boolean, +) { + val satisfied: Boolean get() = verified && !underpaid && !afterExpiry + + fun matches(request: PaykitPaymentRequest): Boolean = + requestId == request.id && proofEventId == request.paymentProofEventId +} + +@Singleton +class PaykitUsdtPaymentRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val keychain: Keychain, + private val sdk: PaykitSdkService, + private val proofs: PaykitPaymentProofRepo, + private val usdt: UsdtRepo, + private val clock: Clock, +) { + companion object { + private val KEY = Keychain.Key.PAYKIT_USDT_PAYMENTS.name + private const val TAG = "PaykitUsdtPaymentRepo" + + fun binding( + request: PaykitPaymentRequest, + identity: String, + paymentAppId: String, + quoteId: String?, + period: BillingPeriod?, + ): UsdtPaymentProofBinding { + val local = PaykitPublicKeys.raw(identity) + val remote = PaykitPublicKeys.raw(request.counterparty) + val incoming = request.direction == PaykitPaymentRequestDirection.Incoming + return UsdtPaymentProofBinding( + payer = if (incoming) local else remote, + payee = if (incoming) remote else local, + paymentAppId = paymentAppId, + paymentRequestId = UUID.fromString(request.paymentRequestId).toString(), + paymentReference = request.paymentReference, + paymentEndpointIdentifier = MethodId.UsdtArbitrum.rawValue, + // EIP-712 binds the proof's timestamp strings, not their normalized spelling. + periodStartsAt = period?.startsAt.orEmpty(), + periodEndsAt = period?.endsAt.orEmpty(), + conversionQuoteId = quoteId.orEmpty(), + ) + } + + fun validateProofSize(binding: UsdtPaymentProofBinding) { + val profile = + PaykitUsdtProof( + UsdtPaymentProof("42161", "0x" + "0".repeat(64), "9".repeat(78), "0x" + "0".repeat(130)) + ) + val message = buildJsonObject { + put("version", 1) + put("kind", "paykit.payment_proof") + put("app_id", "bitkit") + put("event_id", UUID.randomUUID().toString()) + put("payment_request_id", binding.paymentRequestId) + put("payment_reference", binding.paymentReference) + put("payment_app_id", binding.paymentAppId) + put("payment_endpoint_identifier", binding.paymentEndpointIdentifier) + put( + "billing_period", + if (binding.periodStartsAt.isEmpty()) { + JsonNull + } else { + buildJsonObject { + put("starts_at", binding.periodStartsAt) + put("ends_at", binding.periodEndsAt) + } + } + ) + if (binding.conversionQuoteId.isNotEmpty()) put("conversion_quote_id", binding.conversionQuoteId) + put("proof", Json.encodeToJsonElement(PaykitUsdtProof.serializer(), profile)) + } + if (message.toString().toByteArray(Charsets.UTF_8).size > PaykitSdkService.MAX_MESSAGE_BYTES) { + throw PaykitPaymentRequestError.RequestUnavailable + } + } + } + + @Serializable + private data class State( + val attempts: List = emptyList(), + val receipts: List = emptyList(), + ) + + private data class Reconciliation(val wallet: String, val identity: String, val generation: Long) + + private var activeWallet: String? = null + private var activeIdentity: String? = null + private val mutex = Mutex() + private val reconcileMutex = Mutex() + private var stateGeneration = 0L + private var sending = false + + private val _attempts = MutableStateFlow>(emptyList()) + val attempts = _attempts.asStateFlow() + private val _receipts = MutableStateFlow>(emptyList()) + val receipts = _receipts.asStateFlow() + private val _resumableRequests = MutableStateFlow>(emptySet()) + val resumableRequests = _resumableRequests.asStateFlow() + private val unboundAttempts = MutableStateFlow>(emptyList()) + + private val _backupStateVersion = MutableStateFlow(0L) + val backupStateVersion = _backupStateVersion.asStateFlow() + + suspend fun backupSnapshot(): PaykitUsdtStateBackup = withContext(ioDispatcher) { + val state = load() + PaykitUsdtStateBackup(state.attempts, state.receipts.map { PaykitUsdtStateBackup.Receipt(it) }) + } + + suspend fun restoreBackup(backup: PaykitUsdtStateBackup) = withContext(ioDispatcher) { + mutex.withLock { + stateGeneration++ + unboundAttempts.update { emptyList() } + _resumableRequests.update { emptySet() } + val state = load() + val attempts = state.attempts.associateBy { it.wallet to it.quoteId }.toMutableMap() + for (restored in backup.attempts) { + val key = restored.wallet to restored.quoteId + val local = attempts[key] + attempts[key] = local?.copy( + paymentStarted = local.paymentStarted || restored.paymentStarted, + proof = local.proof ?: restored.proof, + ) ?: restored + } + save( + State( + attempts = attempts.values.map { it.copy(proofQueued = false) }, + receipts = (state.receipts + backup.receipts.map { it.restored() }) + .distinctBy { it.wallet to it.paymentId }, + ) + ) + } + } + + suspend fun send( + quote: UsdtQuote, + submissionDeadline: Instant? = null, + beforeSend: suspend () -> Unit = {}, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + mutex.withLock { + if (sending) throw PaykitPaymentRequestError.OperationInProgress + sending = true + stateGeneration++ + } + var sent = false + try { + setPaymentStarted(quote.id, true).getOrThrow() + usdt.send(quote, submissionDeadline, beforeSend).getOrThrow() + sent = true + } finally { + withContext(NonCancellable) { + try { + if (!sent) { + val history = storedTransfer(quote.id) + if (history.isSuccess && history.getOrNull() == null) setPaymentStarted(quote.id, false) + } + } finally { + mutex.withLock { sending = false } + } + } + } + Unit + } + } + + private suspend fun setPaymentStarted(quoteId: String, started: Boolean): Result = withContext(ioDispatcher) { + runSuspendCatching { + mutex.withLock { + val state = load() + val attempt = state.attempts.singleOrNull { + it.quoteId == quoteId && it.wallet == activeWallet && it.identity == activeIdentity + } ?: throw PaykitPaymentRequestError.RequestUnavailable + save( + state.copy( + attempts = state.attempts.map { + if (it == attempt) it.copy(paymentStarted = started) else it + } + ) + ) + if (started) _resumableRequests.update { it - setOfNotNull(attempt.requestId) } + } + } + } + + private fun load(): State = keychain.loadString(KEY)?.let { Json.decodeFromString(it) } ?: State() + + private suspend fun save(state: State) { + keychain.upsertString(KEY, Json.encodeToString(state)) + publish(state) + _backupStateVersion.update { it + 1 } + } + + private fun publish(state: State) { + _attempts.update { state.attempts.filter { it.wallet == activeWallet && it.identity == activeIdentity } } + _receipts.update { state.receipts.filter { it.wallet == activeWallet && it.identity == activeIdentity } } + unboundAttempts.update { known -> known.filter { it in _attempts.value } } + } + + suspend fun protectedRequestIdsForSubscriptionCancellation( + identity: String, + subscriptionId: PaykitSubscriptionId, + ): Result> = withContext(ioDispatcher) { + runSuspendCatching { + mutex.withLock { + val state = load() + val matching = state.attempts.filter { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId?.paymentRequestId == subscriptionId.paymentRequestId && + PubkyPublicKeyFormat.matches(it.requestId.counterparty, subscriptionId.counterparty) && + !it.proofQueued + } + if (matching.isEmpty()) return@withLock emptySet() + val owner = usdt.paymentEndpoint().getOrThrow().value.lowercase() + matching.filter { it.wallet == owner }.mapNotNull { attempt -> + val transfer = storedTransfer(attempt.quoteId).getOrThrow() + val terminalFailure = transfer?.status in setOf( + UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED, + ) + attempt.requestId.takeIf { + !terminalFailure && (attempt.paymentStarted || attempt.proof != null || transfer != null) + } + }.toSet() + } + } + } + + suspend fun verifiedReceipts(identity: String): Result> = withContext(ioDispatcher) { + runSuspendCatching { + mutex.withLock { + val matching = load().receipts.filter { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.satisfied + } + if (matching.isEmpty()) return@withLock emptyList() + val owner = usdt.paymentEndpoint().getOrThrow().value.lowercase() + matching.filter { it.wallet == owner } + } + } + } + + fun hasBinding(requestId: PaykitPaymentRequestId): Boolean = + runCatching { + load().attempts.any { it.requestId == requestId && it !in unboundAttempts.value } + }.getOrDefault(true) + + suspend fun prepare( + context: ContactPaymentContext, + quote: UsdtQuote, + amount: PaykitAmount?, + paymentTerms: PaykitRequestPayment?, + ) = + withContext(ioDispatcher) { + runSuspendCatching { + quote.requireValidForSubmission(clock.now()) + mutex.withLock { + val identity = currentIdentity(Priority.Interactive) + ?: throw PaykitPaymentRequestError.RequestUnavailable + validateQuote(context, quote, amount, paymentTerms) + val request = context.incomingPaymentRequest + val owner = usdt.paymentEndpoint().getOrThrow().value.lowercase() + quote.requireValidForSubmission(clock.now()) + activate(owner, identity) + val state = load() + validateReplacement(state, owner, identity, request, quote.id) + val binding = request?.let { + binding( + it, + identity, + context.privatePaymentContext?.paymentAppsByEndpoint?.get(MethodId.UsdtArbitrum.rawValue) + ?: throw PaykitPaymentRequestError.RequestUnavailable, + paymentTerms?.quoteId, + it.billingPeriod?.sdkValue + ) + } + binding?.let(::validateProofSize) + val attempt = + PaykitUsdtAttempt( + quote.id, + owner, + identity, + context.publicKey, + request?.id, + binding, + request?.billingPeriod + ) + val existing = state.attempts.firstOrNull { it.wallet == owner && it.quoteId == quote.id } + val previousBinding = existing?.bindingState() + if (previousBinding != null && previousBinding != attempt) { + throw PaykitPaymentRequestError.OperationInProgress + } + save( + state.copy( + attempts = state.attempts.filterNot { + it.wallet == owner && it.identity == identity && + (it.quoteId == quote.id || (request != null && it.requestId == request.id)) + } + (existing ?: attempt) + ) + ) + } + } + } + + private fun validateQuote( + context: ContactPaymentContext, + quote: UsdtQuote, + amount: PaykitAmount?, + paymentTerms: PaykitRequestPayment?, + ) { + val recipient = context.endpoints.firstOrNull { it.methodId == MethodId.UsdtArbitrum } + ?.let { PaykitUsdt.address(it.rawPayload) } + if (recipient == null || quote.destination != UsdtDestination.ARBITRUM || + !quote.recipient.equals(recipient, true) + ) { + throw PaykitPaymentRequestError.RequestUnavailable + } + val payment = PaykitAmount(PaykitAsset.USDT, quote.amount) + if (payment != amount || quote.amount == 0uL) throw PaykitPaymentRequestError.RequestUnavailable + context.incomingPaymentRequest?.let { validateRequestTerms(it, payment, paymentTerms) } + } + + private fun validateRequestTerms( + request: PaykitPaymentRequest, + payment: PaykitAmount, + terms: PaykitRequestPayment?, + ) { + val now = clock.now() + val isAvailable = MethodId.UsdtArbitrum.rawValue in request.acceptedPaymentEndpointIdentifiers && + !request.isExpired(now) + if (!isAvailable || terms == null || !terms.isValid(now)) throw PaykitPaymentRequestError.RequestUnavailable + if (terms.amount != payment || request.payment(MethodId.UsdtArbitrum, now, terms.quoteId) != terms) { + throw PaykitPaymentRequestError.RequestUnavailable + } + } + + private suspend fun validateReplacement( + state: State, + owner: String, + identity: String, + request: PaykitPaymentRequest?, + quoteId: String, + ) { + if (request == null) return + val previous = state.attempts.firstOrNull { + it.wallet == owner && it.identity == identity && it.requestId == request.id + }?.takeIf { it.quoteId != quoteId } ?: return + val transfer = storedTransfer(previous.quoteId).getOrThrow() + if (transfer.isUnresolved(previous.paymentStarted)) throw PaykitPaymentRequestError.OperationInProgress + } + + private fun UsdtTransfer?.isUnresolved(started: Boolean): Boolean = if (this == null) { + started + } else { + status !in setOf(UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED) + } + + suspend fun reconcile() = withContext(ioDispatcher) { + runSuspendCatching { + reconcileMutex.withLock { + val generation = mutex.withLock snapshot@{ + if (sending) return@snapshot null + unboundAttempts.update { emptyList() } + _resumableRequests.update { emptySet() } + stateGeneration + } ?: return@withLock + val identity = currentIdentity() ?: return@withLock + val owner = usdt.paymentEndpoint().getOrThrow().value.lowercase() + val (reconciliation, state) = mutex.withLock snapshot@{ + if (stateGeneration != generation) return@snapshot null + activate(owner, identity) + val state = load() + publish(state) + Reconciliation(owner, identity, stateGeneration) to state + } ?: return@withLock + reconcileAttempts(reconciliation, state) + reconcileReceipts(reconciliation) + } + } + } + + private fun activate(owner: String, identity: String) { + if (activeWallet != owner || activeIdentity != identity) { + stateGeneration++ + unboundAttempts.update { emptyList() } + _resumableRequests.update { emptySet() } + } + activeWallet = owner + activeIdentity = identity + } + + private fun isCurrent(reconciliation: Reconciliation): Boolean = + stateGeneration == reconciliation.generation && activeWallet == reconciliation.wallet && + activeIdentity == reconciliation.identity + + private suspend fun reconcileAttempts(reconciliation: Reconciliation, state: State) { + val localAttempts = state.attempts.filter { + it.wallet == reconciliation.wallet && it.identity == reconciliation.identity + } + val resumable = mutableListOf() + val unbound = mutableListOf() + for (attempt in localAttempts) { + val requestId = attempt.requestId ?: continue + if (!mutex.withLock { isCurrent(reconciliation) }) return + runSuspendCatching { + val transfer = storedTransfer(attempt.quoteId).getOrThrow() + val current = if (transfer == null) { + updateAttempt(reconciliation, attempt) { it.copy(paymentStarted = false) } + ?: return@runSuspendCatching + } else { + attempt + } + if (!transfer.isUnresolved(current.paymentStarted)) resumable += current + if (current.canReleaseBinding(transfer)) { + unbound += current + } + if (!attempt.proofQueued && + (attempt.proof != null || transfer?.status == UsdtTransferStatus.CONFIRMED) + ) { + submitAttemptProof(reconciliation, attempt, requestId) + } + }.onFailure { Logger.warn("Failed to reconcile a Paykit USDT payment attempt", it, context = TAG) } + } + mutex.withLock { + if (!isCurrent(reconciliation)) return@withLock + val current = load() + publish(current) + unboundAttempts.update { unbound.filter { it in current.attempts } } + _resumableRequests.update { + resumable.filter { it in current.attempts }.mapNotNull { it.requestId }.toSet() + } + } + } + + private fun PaykitUsdtAttempt.canReleaseBinding(transfer: UsdtTransfer?): Boolean = + proof == null && !proofQueued && !transfer.isUnresolved(paymentStarted) + + private suspend fun submitAttemptProof( + reconciliation: Reconciliation, + attempt: PaykitUsdtAttempt, + requestId: PaykitPaymentRequestId, + ) { + if (!mutex.withLock { isCurrent(reconciliation) }) return + val binding = attempt.binding ?: return + val proof = attempt.proof + ?: paymentProof(attempt.quoteId, binding).getOrThrow()?.let(::PaykitUsdtProof) + ?: return + val saved = updateAttempt(reconciliation, attempt) { it.copy(proof = it.proof ?: proof) } ?: return + if (saved.proof != proof || saved.proofQueued) return + val pending = PendingPaykitPaymentProof( + identity = attempt.identity, + requestId = requestId, + paymentAppId = binding.paymentAppId, + paymentEndpointIdentifier = binding.paymentEndpointIdentifier, + kind = PaykitPaymentProofKind.Usdt, + paymentIdentifier = attempt.quoteId, + proofData = Json.encodeToString(proof), + billingPeriod = attempt.billingPeriod, + conversionQuoteId = binding.conversionQuoteId.takeIf { it.isNotEmpty() }, + ) + if (proofs.submit(pending).getOrThrow()) { + updateAttempt(reconciliation, saved) { if (it.proof == proof) it.copy(proofQueued = true) else it } + } + } + + private suspend fun updateAttempt( + reconciliation: Reconciliation, + attempt: PaykitUsdtAttempt, + update: (PaykitUsdtAttempt) -> PaykitUsdtAttempt, + ): PaykitUsdtAttempt? = mutex.withLock { + if (!isCurrent(reconciliation)) return@withLock null + val state = load() + val current = state.attempts.singleOrNull { it.bindingState() == attempt.bindingState() } + ?: return@withLock null + val updated = update(current) + if (updated != current) save(state.copy(attempts = state.attempts.map { if (it == current) updated else it })) + updated + } + + private fun PaykitUsdtAttempt.bindingState() = copy(proof = null, proofQueued = false, paymentStarted = false) + + private suspend fun reconcileReceipts(reconciliation: Reconciliation) { + if (!mutex.withLock { isCurrent(reconciliation) }) return + for (record in sdk.paymentRequests().filter { it.localRole == PaymentRequestLocalRole.PAYEE }) { + val submissions = record.paymentProofs.filter { + it.paymentEndpointIdentifier == MethodId.UsdtArbitrum.rawValue + } + for (submission in submissions) { + if (!mutex.withLock { isCurrent(reconciliation) }) return + reconcileReceipt(record, submission, reconciliation) + } + } + } + + private fun receivedRequest(record: PaymentRequestRecord, submission: PaymentProofRecord): PaykitPaymentRequest? { + val period = submission.billingPeriod + val request = if (period != null) { + val startsAt = runCatching { Instant.parse(period.startsAt) }.getOrNull() ?: return null + val endsAt = runCatching { Instant.parse(period.endsAt) }.getOrNull() ?: return null + record.toPaykitSubscription()?.receivedPaymentRequests()?.firstOrNull { + it.billingPeriod?.startsAt == startsAt && it.billingPeriod.endsAt == endsAt + } + } else { + record.toPaykitPaymentRequestHistory(clock.now()) + } + return request?.takeIf { submission.paymentEndpointIdentifier in it.acceptedPaymentEndpointIdentifiers } + } + + private fun submittedProof(submission: PaymentProofRecord): PaykitUsdtProof? = runCatching { + Json.decodeFromString(submission.proof.exportText()) + }.getOrNull()?.takeIf { it.type == "erc20-transfer-eip712" && submission.paymentAppId == "bitkit" } + + private suspend fun reconcileReceipt( + record: PaymentRequestRecord, + submission: PaymentProofRecord, + reconciliation: Reconciliation, + ) { + val request = receivedRequest(record, submission) ?: return + val proof = submittedProof(submission) ?: return + val binding = binding( + request, + reconciliation.identity, + submission.paymentAppId, + submission.conversionQuoteId, + submission.billingPeriod + ) + val paymentResult = verifyPayment(binding, proof.coreValue) + if (paymentResult.exceptionOrNull() is UsdtException.InvalidPaymentProof || + paymentResult.isSuccess && paymentResult.getOrNull() == null + ) { + invalidateReceipt(submission.eventId, request.id, reconciliation) + return + } + val payment = paymentResult.getOrThrow() ?: return + val receivedAt = Instant.fromEpochSeconds(payment.timestamp.toLong()) + val terms = runCatching { + request.payment( + MethodId.UsdtArbitrum, + receivedAt, + submission.conversionQuoteId + ) + }.getOrNull() + if (terms != null) { + val amount = PaykitAmount(PaykitAsset.USDT, payment.amount) + val receipt = PaykitUsdtReceipt( + wallet = reconciliation.wallet, + identity = reconciliation.identity, + requestId = request.id, + paymentId = payment.paymentId, + proofEventId = submission.eventId, + verified = true, + transferId = payment.transferId, + amount = amount, + receivedAtMillis = receivedAt.toEpochMilliseconds(), + underpaid = amount.atomic < terms.amount.atomic, + afterExpiry = !terms.isValid(receivedAt), + ) + mutex.withLock { + if (!isCurrent(reconciliation)) return@withLock + val current = load() + val samePayment = current.receipts.filter { + it.wallet == reconciliation.wallet && it.paymentId == payment.paymentId + } + if (samePayment.any { it.identity != reconciliation.identity || it.requestId != request.id }) { + return@withLock + } + if (samePayment.singleOrNull() != receipt) { + save(current.copy(receipts = current.receipts - samePayment.toSet() + receipt)) + } + } + } + } + + private suspend fun invalidateReceipt( + proofEventId: String, + requestId: PaykitPaymentRequestId, + reconciliation: Reconciliation, + ) = mutex.withLock { + if (!isCurrent(reconciliation)) return@withLock + val current = load() + val matching = current.receipts.filter { + it.wallet == reconciliation.wallet && it.identity == reconciliation.identity && it.requestId == requestId + }.filter { it.proofEventId == proofEventId && it.verified }.toSet() + if (matching.isEmpty()) return@withLock + save(current.copy(receipts = current.receipts.map { if (it in matching) it.copy(verified = false) else it })) + } + + private suspend fun paymentProof(quoteId: String, binding: UsdtPaymentProofBinding) = + usdt.operation { this.paymentProof(quoteId, binding) } + private suspend fun storedTransfer(id: String) = usdt.operation { wallet().history().firstOrNull { it.id == id } } + private suspend fun verifyPayment(binding: UsdtPaymentProofBinding, proof: UsdtPaymentProof) = + usdt.operation { wallet().verifyPaymentProof(binding, proof) } + + private suspend fun currentIdentity(priority: Priority = Priority.Ordered): String? = + sdk.identityStatus(priority)?.takeIf { it.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } + ?.publicKey?.let(PubkyPublicKeyFormat::normalized) +} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 5553481088..960c29d236 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -85,6 +85,7 @@ class PrivatePaykitRepo @Inject constructor( private val walletRepo: WalletRepo, private val publicPaykitRepo: PublicPaykitRepo, private val paymentRequestRepo: PaykitPaymentRequestRepo, + private val usdtRepo: UsdtRepo, private val coreService: CoreService, private val clock: Clock, ) { @@ -292,9 +293,15 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun scheduleContactPreparation(publicKeys: Collection, forceRefreshLightning: Boolean = false) { + private fun scheduleContactPreparation( + publicKeys: Collection, + forceRefreshLightning: Boolean = false, + requeueActive: Boolean = false, + ) { if (isDeletingProfile) return - pendingPreparationKeys.addAll(publicKeys.filter { forceRefreshLightning || it !in activePreparationKeys }) + pendingPreparationKeys.addAll( + publicKeys.filter { requeueActive || forceRefreshLightning || it !in activePreparationKeys } + ) pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning if (preparationJob?.isActive == true || pendingPreparationKeys.isEmpty()) return preparationJob = retryScope.launch { @@ -376,9 +383,10 @@ class PrivatePaykitRepo @Inject constructor( suspend fun refreshKnownSavedContactEndpoints( reason: String, forceRefreshLightning: Boolean = false, + requeueActive: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - scheduleContactPreparation(knownSavedContactKeys.toList(), forceRefreshLightning) + scheduleContactPreparation(knownSavedContactKeys.toList(), forceRefreshLightning, requeueActive) }.onFailure { Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG) } @@ -695,7 +703,8 @@ class PrivatePaykitRepo @Inject constructor( pubkyService.currentPublicKey() ?: return@runSuspendCatching null json.encodeToString( PrivatePaykitBackup( - sdkState = paykitSdkService.exportBackupState(), + // This snapshot can be required before broadcast; it must not wait for payment to finish. + sdkState = paykitSdkService.exportBackupState(Priority.Interactive), consumedPrivatePaymentListVersions = ensureState().contacts .mapNotNull { (publicKey, contactState) -> contactState.consumedPrivatePaymentListVersion?.let { publicKey to it } @@ -870,6 +879,7 @@ class PrivatePaykitRepo @Inject constructor( paymentListVersion = paymentListVersion, previousPaymentListVersion = consumedVersion.takeIf { paymentListVersion != null }, ), + endpoints = privatePayable, ) } @@ -1633,6 +1643,14 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val settings = settingsStore.data.first() val endpoints = mutableListOf() + if (PublicPaykitRepo.isUsdtPaymentOptionEnabled(settings, usdtRepo.state.value)) { + usdtRepo.paymentEndpoint() + .onSuccess { endpoints += it } + .onFailure { + if (it is CancellationException) throw it + Logger.warn("Failed to prepare private Paykit USDT endpoint", it, context = TAG) + } + } if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { val reservedAddress = addressReservationRepo.currentOrRotatedAddress( publicKey, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 2316dcecd4..a6f18a585c 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import androidx.annotation.VisibleForTesting import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.validateBitcoinAddress +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex @@ -16,6 +17,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PaykitUsdt import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService @@ -46,6 +48,7 @@ sealed interface PublicPaykitPaymentResult { data class Opened( val paymentRequest: String, val privatePaymentContext: PrivatePaykitPaymentContext? = null, + val endpoints: List = emptyList(), ) : PublicPaykitPaymentResult data object NoEndpoint : PublicPaykitPaymentResult @@ -102,8 +105,10 @@ class PublicPaykitRepo @Inject constructor( private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, private val clock: Clock, + private val usdtRepo: UsdtRepo, ) { companion object { + private const val TAG = "PublicPaykitRepo" private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") internal val payablePreferenceOrder = listOf( @@ -113,6 +118,7 @@ class PublicPaykitRepo @Inject constructor( MethodId.P2wpkh, MethodId.P2sh, MethodId.P2pkh, + MethodId.UsdtArbitrum, ) private val publicBolt11Expiry = 24.hours @@ -127,6 +133,9 @@ class PublicPaykitRepo @Inject constructor( fun isOnchainPaymentOptionEnabled(settings: SettingsData): Boolean = settings.publicPaykitOnchainEnabled + fun isUsdtPaymentOptionEnabled(settings: SettingsData, wallet: UsdtWalletState): Boolean = + settings.isUsdtEnabled(wallet) && settings.publicPaykitUsdtEnabled + fun parseEndpoint( methodId: String, endpointData: String, @@ -135,6 +144,11 @@ class PublicPaykitRepo @Inject constructor( if (!methodIdPattern.matches(methodId)) return null val knownMethodId = MethodId.fromRawValue(methodId, network) ?: return null + if (knownMethodId == MethodId.UsdtArbitrum) { + return PaykitUsdt.address(endpointData)?.let { + Endpoint(methodId = knownMethodId, value = it, rawPayload = endpointData) + } + } val payload = PaykitIssuerInterop.parseEndpointPayload(endpointData) ?: return null return Endpoint( @@ -196,7 +210,7 @@ class PublicPaykitRepo @Inject constructor( val payable = endpoints.filter { isPayable(it) } if (payable.isEmpty()) return@runSuspendCatching PublicPaykitPaymentResult.NotOpened - PublicPaykitPaymentResult.Opened(paymentRequest(payable)) + PublicPaykitPaymentResult.Opened(paymentRequest(payable), endpoints = payable) } } @@ -357,6 +371,15 @@ class PublicPaykitRepo @Inject constructor( ) } + if (isUsdtPaymentOptionEnabled(settings, usdtRepo.state.value)) { + usdtRepo.paymentEndpoint() + .onSuccess { endpoints += it } + .onFailure { + if (it is CancellationException) throw it + Logger.warn("Failed to prepare public Paykit USDT endpoint", it, context = TAG) + } + } + if (endpoints.isEmpty() && requireEndpoint) throw PublicPaykitError.NoSupportedEndpoint return endpoints @@ -433,6 +456,7 @@ class PublicPaykitRepo @Inject constructor( private suspend fun isPayable(endpoint: Endpoint): Boolean = runSuspendCatching { when (endpoint.methodId) { + MethodId.UsdtArbitrum -> Env.isUsdtEnabled && PaykitUsdt.address(endpoint.rawPayload) != null MethodId.Bolt11 -> { val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.Lightning ?: return@runSuspendCatching false @@ -463,7 +487,7 @@ data class Endpoint( val appId: String? = null, ) { val paymentRequest: String - get() = value + get() = if (methodId == MethodId.UsdtArbitrum) PaykitUsdt.paymentUri(value) else value } enum class MethodId( @@ -472,6 +496,7 @@ enum class MethodId( val isOnchain: Boolean = false, val isBitkitManaged: Boolean = false, ) { + UsdtArbitrum(fixedRawValue = "usdt-arbitrum-address", isBitkitManaged = true), Bolt11(fixedRawValue = "btc-lightning-bolt11", isBitkitManaged = true), Lnurl(fixedRawValue = "btc-lightning-lnurl"), P2tr(onchainEndpoint = "p2tr", isOnchain = true, isBitkitManaged = true), diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 8831d90d9c..39e10d7ade 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -75,6 +75,7 @@ import to.bitkit.models.deriveHardwareWalletId import to.bitkit.models.findHardwareWalletId import to.bitkit.models.isReplacedBy import to.bitkit.models.matches +import to.bitkit.models.storedAccountKey import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString @@ -1317,7 +1318,7 @@ class TrezorRepo @Inject constructor( path = path, coin = coin, showOnTrezor = false, - ).xpub + ).storedAccountKey(addressType) } if (result.isSuccess) { return XpubFetchResult(xpub = result.getOrThrow()) diff --git a/app/src/main/java/to/bitkit/repositories/UsdtDepositRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtDepositRepo.kt new file mode 100644 index 0000000000..3c134f6d23 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/UsdtDepositRepo.kt @@ -0,0 +1,23 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.UsdtDepositNetwork +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UsdtDepositRepo @Inject constructor(private val usdt: UsdtRepo) { + suspend fun depositNetworks() = usdt.operation { depositNetworks().toImmutableList() } + suspend fun prepareDeposit(network: UsdtDepositNetwork, amount: String) = + usdt.operation { prepareDeposit(network, amount) } + suspend fun depositHistory(offset: UInt) = usdt.operation { + depositHistory(offset).also { usdt.updateDeposits(it.deposits) } + } + suspend fun depositDetail(id: String, offset: UInt) = usdt.operation { + depositDetail(id, offset).also { detail -> + usdt.updateDeposits(listOf(detail.deposit.copy(status = detail.order?.status ?: detail.deposit.status))) + } + } + suspend fun refundDeposit(id: String, offset: UInt, address: String, network: UsdtDepositNetwork) = + usdt.operation { refundDeposit(id, offset, address, network) } +} diff --git a/app/src/main/java/to/bitkit/repositories/UsdtPreferencesRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtPreferencesRepo.kt new file mode 100644 index 0000000000..bdd3541d8d --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/UsdtPreferencesRepo.kt @@ -0,0 +1,91 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtTransferStatus +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.data.UsdtPreference +import to.bitkit.data.isUsdtEnabled +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.utils.AppError +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UsdtPreferencesRepo @Inject constructor( + private val settingsStore: SettingsStore, + private val usdtRepo: UsdtRepo, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + val enabled = combine(settingsStore.data, usdtRepo.state) { settings, wallet -> + settings.isUsdtEnabled(wallet) + } + val visible = combine(settingsStore.data, usdtRepo.state) { settings, wallet -> + Env.isUsdtEnabled && (settings.isUsdtEnabled || wallet.hasFundsOrPendingTransfers) + } + val canDisable = usdtRepo.state.map { it.canDisable } + + suspend fun setEnabled(enabled: Boolean): Result = withContext(ioDispatcher) { + runSuspendCatching { + val settings = settingsStore.data.first() + if (!enabled && settings.usdtPreference != UsdtPreference.UNDECIDED && + settings.isUsdtEnabled(usdtRepo.state.value) + ) { + checkCanDisable().getOrThrow() + } + settingsStore.update { + it.copy(usdtPreference = if (enabled) UsdtPreference.ENABLED else UsdtPreference.DISABLED) + } + } + } + + suspend fun checkCanDisable(): Result = withContext(ioDispatcher) { + runSuspendCatching { + usdtRepo.refresh(history = false, force = true).getOrThrow() + if (usdtRepo.state.value.balance != 0uL) throw UsdtDisableError() + if (Env.usdtDepositsUrl != null) { + usdtRepo.operation { + var offset: UInt? = 0u + while (offset != null) { + val page = depositHistory(offset) + usdtRepo.updateDeposits(page.deposits) + if (page.deposits.any { it.needsSettlement }) throw UsdtDisableError() + val next = page.nextOffset + if (next != null && next <= offset) throw UsdtDisableError() + offset = next + } + }.getOrThrow() + } + if (!usdtRepo.state.value.canDisable) throw UsdtDisableError() + } + } +} + +internal val UsdtWalletState.hasFundsOrPendingTransfers: Boolean + get() = hasKnownFundsOrPendingTransfers || (balance == null && transfers.isNotEmpty()) + +internal val UsdtWalletState.hasKnownFundsOrPendingTransfers: Boolean + get() = pendingDepositIds.isNotEmpty() || (balance ?: 0uL) > 0uL || transfers.any { + it.status in setOf( + UsdtTransferStatus.PENDING, UsdtTransferStatus.BRIDGING, + UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, UsdtTransferStatus.BRIDGE_FAILED, + ) + } + +internal val UsdtWalletState.canDisable: Boolean + get() = balance == 0uL && !hasFundsOrPendingTransfers + +class UsdtDisableError : AppError("USDT balance or transfers need attention") + +internal val UsdtDeposit.needsSettlement: Boolean + get() = status !in setOf("completed", "refunded", "waiting", "awaiting_deposit", "expired") + +internal fun SettingsData.isUsdtEnabled(wallet: UsdtWalletState): Boolean = + Env.isUsdtEnabled && (isUsdtEnabled || wallet.hasKnownFundsOrPendingTransfers) diff --git a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt new file mode 100644 index 0000000000..1889dc2ac7 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt @@ -0,0 +1,266 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Stable +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.usdtParseAmount +import com.synonym.bitkitcore.usdtParsePaymentRequest +import com.synonym.bitkitcore.usdtValidateRecipient +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PaykitUsdt +import to.bitkit.services.UsdtService +import to.bitkit.utils.AppError +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@Suppress("TooManyFunctions") +@Singleton +class UsdtRepo @Inject constructor( + private val service: UsdtService, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val clock: Clock = Clock.System, +) { + private val mutex = Mutex() + + @Volatile var isSendPresented = false + + private var nextRefresh = 0L + private var rateLimitedUntil = 0L + private val _state = MutableStateFlow(UsdtWalletState()) + val state = _state.asStateFlow() + private val _receivedTxs = MutableSharedFlow(extraBufferCapacity = 8) + val receivedTxs = _receivedTxs.asSharedFlow() + + suspend fun refresh(history: Boolean, force: Boolean = false) = withContext(ioDispatcher) { + mutex.withLock { + val refreshAfter = if (force) rateLimitedUntil else maxOf(nextRefresh, rateLimitedUntil) + if (nowMillis() < refreshAfter) { + return@withLock if (force) Result.failure(UsdtException.RateLimited()) else Result.success(Unit) + } + runSuspendCatching { + val wallet = service.wallet() + val address = wallet.receiveAddress() + if (_state.value.address != address) _state.update { UsdtWalletState(address = address) } + val previous = _state.value.transfers.takeIf { _state.value.historyComplete } + val saved = wallet.history().toImmutableList() + _state.update { it.copy(receiveUri = wallet.receiveUri(), transfers = saved) } + val settlement = runSuspendCatching { + val balance = wallet.balance() + _state.update { it.copy(balance = balance) } + val transfers = wallet.refreshTransfers().toImmutableList() + _state.update { it.copy(transfers = transfers) } + } + if (settlement.exceptionOrNull() is UsdtException.RateLimited) settlement.getOrThrow() + if (history && !isSendPresented) { + val complete = wallet.syncHistory() + val current = wallet.history().toImmutableList() + _state.update { it.copy(historyComplete = it.historyComplete || complete, transfers = current) } + announceNewReceipts(previous, current) + } + settlement.getOrThrow() + }.also { + if (it.exceptionOrNull() is UsdtException.RateLimited) { + rateLimitedUntil = nowMillis() + 60.seconds.inWholeMilliseconds + } + nextRefresh = maxOf(rateLimitedUntil, nowMillis() + 10.seconds.inWholeMilliseconds) + } + } + } + + private suspend fun announceNewReceipts(previous: List?, current: List) { + if (previous == null) return + val knownIds = previous.filter { it.isIncoming && it.status == UsdtTransferStatus.CONFIRMED } + .map { it.id }.toSet() + current.filter { + it.isIncoming && it.status == UsdtTransferStatus.CONFIRMED && it.amount > 0u && it.id !in knownIds + }.forEach { _receivedTxs.emit(it) } + } + + suspend fun waitForTransfer(id: String): Result = withContext(ioDispatcher) { + withTimeoutOrNull(5.seconds) { + mutex.withLock { + if (nowMillis() < rateLimitedUntil) return@withLock Result.success(Unit) + runSuspendCatching { + val wallet = service.wallet() + while (true) { + val transfer = wallet.checkRecentExecution(id) ?: return@runSuspendCatching + _state.update { + val transfers = it.transfers.filter { saved -> saved.id != transfer.id } + it.copy(transfers = (listOf(transfer) + transfers).toImmutableList()) + } + if (transfer.status != UsdtTransferStatus.PENDING) return@runSuspendCatching + delay(1.seconds) + } + }.onFailure { + if (it is UsdtException.RateLimited) { + rateLimitedUntil = nowMillis() + 60.seconds.inWholeMilliseconds + nextRefresh = maxOf(nextRefresh, rateLimitedUntil) + } + } + } + } ?: Result.success(Unit) + } + + suspend fun paymentEndpoint(): Result = operation { PaykitUsdt.endpoint(wallet().receiveAddress()) } + + suspend fun sendDestinations() = operation { + (Env.usdtDestinations + service.wallet().orchestraDestinations()).distinct().toImmutableList() + } + + fun hasStoredWallet(): Boolean = runCatching { service.hasStoredWallet }.getOrDefault(false) + + fun shouldRefresh(enabled: Boolean): Boolean = enabled || state.value.hasFundsOrPendingTransfers || + (state.value.balance == null && hasStoredWallet()) + + fun backupSnapshot(): String? = service.backupSnapshot() + + suspend fun restoreBackup(snapshot: String) = withContext(ioDispatcher) { + mutex.withLock { + runSuspendCatching { + val wallet = service.wallet(requiresNetwork = false) + wallet.restoreBackup(snapshot) + _state.update { + it.copy( + address = wallet.receiveAddress(), + transfers = wallet.history().toImmutableList(), + historyComplete = false, + pendingDepositIds = persistentSetOf(), + ) + } + } + } + } + + internal fun updateDeposits(deposits: List) { + _state.update { wallet -> + wallet.copy( + pendingDepositIds = ( + wallet.pendingDepositIds - deposits.map { it.id }.toSet() + + deposits.filter { it.needsSettlement }.map { it.id } + ).toImmutableSet(), + ) + } + } + + internal suspend fun operation(block: suspend UsdtService.() -> T): Result = withContext(ioDispatcher) { + mutex.withLock { runSuspendCatching { block(service) } } + } + + suspend fun wipe(clearCredentials: suspend () -> Unit = {}) = withContext(ioDispatcher) { + mutex.withLock { + clearCredentials() + runSuspendCatching { service.wipe() }.also { + _state.update { UsdtWalletState() } + nextRefresh = 0L + rateLimitedUntil = 0L + } + } + } + + suspend fun quote( + recipient: String, + amount: String, + destination: UsdtDestination, + locale: Locale = Locale.getDefault(), + ) = withContext(ioDispatcher) { + mutex.withLock { + runSuspendCatching { + val wallet = service.wallet() + if (recipient.trim().equals(wallet.receiveAddress(), ignoreCase = true)) throw UsdtSelfSendError() + wallet.quoteTransfer( + recipient, + usdtParseAmount(amount.replace(DecimalFormatSymbols.getInstance(locale).decimalSeparator, '.')), + destination + ) + } + } + } + + suspend fun quoteMaximum(recipient: String, destination: UsdtDestination) = withContext(ioDispatcher) { + mutex.withLock { + runSuspendCatching { + val wallet = service.wallet() + if (recipient.trim().equals(wallet.receiveAddress(), ignoreCase = true)) throw UsdtSelfSendError() + wallet.quoteMaxTransfer(recipient, destination) + } + } + } + + suspend fun send( + quote: UsdtQuote, + submissionDeadline: Instant? = null, + beforeSend: suspend () -> Unit = {}, + ) = withContext(ioDispatcher) { + mutex.withLock { + runSuspendCatching { + beforeSend() + quote.requireValidForSubmission(clock.now()) + service.send(quote, submissionDeadline).also { transfer -> + _state.update { + it.copy( + transfers = (listOf(transfer) + it.transfers.filter { saved -> saved.id != transfer.id }) + .toImmutableList() + ) + } + } + }.onFailure { + runSuspendCatching { service.wallet().history().toImmutableList() } + .onSuccess { transfers -> _state.update { it.copy(transfers = transfers) } } + } + } + } +} + +internal fun UsdtQuote.requireValidForSubmission(now: Instant) { + if (expiresAt <= (now + 5.seconds).epochSeconds.toULong()) throw UsdtException.QuoteExpired() +} + +fun parseUsdtPaymentRequest(value: String, destination: UsdtDestination = UsdtDestination.ARBITRUM) = runCatching { + if (value.contains(':')) { + usdtParsePaymentRequest(value) + } else { + UsdtPaymentRequest(usdtValidateRecipient(value, destination), null, null) + } +} + +@Stable +data class UsdtWalletState( + val address: String = "", + val receiveUri: String = "", + val balance: ULong? = null, + val transfers: ImmutableList = persistentListOf(), + val historyComplete: Boolean = false, + val pendingDepositIds: ImmutableSet = persistentSetOf(), +) + +class UsdtSelfSendError : AppError("Cannot send USDT to own address") diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index fcd4038ad6..8c39f207d1 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -20,6 +20,8 @@ import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentAmountContext +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentProofSubmission import com.synonym.paykit.PaymentReference @@ -106,7 +108,6 @@ import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PaykitBillingPeriod -import to.bitkit.repositories.PaykitIssuerInterop import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.services.PaykitSdkOperationLock.Priority @@ -144,11 +145,14 @@ data class PaykitResolvedPaymentEndpoint( data class PaykitPaymentRequestProposalTerms( val amountValue: String, + val amountAsset: String, val paymentReference: String, val proposalExpiresAt: String, val acceptedPaymentEndpointIdentifiers: List, val metadataJson: String, val recurrence: PaykitPaymentRequestRecurrenceTerms? = null, + val conversion: PaymentConversion? = null, + val paymentDeadline: PaymentDeadline? = null, ) data class PaykitPaymentRequestRecurrenceTerms( @@ -1051,7 +1055,7 @@ class PaykitSdkService @Inject constructor( PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before proposing the payment request" } val terms = PaymentRequestTerms( - amount = PaymentRequestAmount(proposal.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), + amount = PaymentRequestAmount(proposal.amountValue, proposal.amountAsset), paymentReference = PaymentReference(proposal.paymentReference), proposalExpiresAt = proposal.proposalExpiresAt, recurrence = proposal.recurrence?.let { @@ -1066,8 +1070,8 @@ class PaykitSdkService @Inject constructor( acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, paymentEndpoints = null, requiredAppId = "bitkit", - conversion = null, - paymentDeadline = null, + conversion = proposal.conversion, + paymentDeadline = proposal.paymentDeadline, metadata = PrivateJsonObject(proposal.metadataJson), ) completeSdkCall { handle.proposePaymentRequest(counterparty, terms) } @@ -1107,6 +1111,7 @@ class PaykitSdkService @Inject constructor( paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, + conversionQuoteId: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { @@ -1120,7 +1125,7 @@ class PaykitSdkService @Inject constructor( paymentAppId = paymentAppId, paymentEndpointIdentifier = paymentEndpointIdentifier, allowanceId = null, - conversionQuoteId = null, + conversionQuoteId = conversionQuoteId, proof = PrivateJsonObject(proofJson), ), ) @@ -1258,16 +1263,16 @@ class PaykitSdkService @Inject constructor( }, ) - suspend fun exportBackupState(): String { + internal suspend fun exportBackupState(priority: Priority = Priority.Background): String { isSetup.await() val generation = runtimeGeneration return operationLock.withoutLock { var backup: String? do { - isPaymentSubmissionActive.first { !it } - backup = operationLock.withLock(Priority.Background) { + if (priority == Priority.Background) isPaymentSubmissionActive.first { !it } + backup = operationLock.withLock(priority) { check(runtimeGeneration == generation) { "Paykit runtime changed before backup export" } - if (isPaymentSubmissionActive.value) return@withLock null + if (priority == Priority.Background && isPaymentSubmissionActive.value) return@withLock null withPaykitKey { completeSdkCall { it.exportBackupString() } } } } while (backup == null) @@ -1536,6 +1541,9 @@ class PaykitSdkService @Inject constructor( companion object { private const val TAG = "PaykitSdkService" + /** Maximum plaintext size accepted by Paykit's pubky-noise transport. */ + const val MAX_MESSAGE_BYTES = 1000 + /** Minimum delay between successful identity republications. */ private val IDENTITY_REPUBLISH_INTERVAL = 30.minutes diff --git a/app/src/main/java/to/bitkit/services/UsdtService.kt b/app/src/main/java/to/bitkit/services/UsdtService.kt new file mode 100644 index 0000000000..370024ed08 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/UsdtService.kt @@ -0,0 +1,132 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.UsdtBackup +import com.synonym.bitkitcore.UsdtDepositClient +import com.synonym.bitkitcore.UsdtDepositNetwork +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentProofBinding +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtWallet +import com.synonym.bitkitcore.usdtAddress +import com.synonym.bitkitcore.usdtParseAmount +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.repositories.BackupRepo +import java.io.File +import java.io.IOException +import java.security.MessageDigest +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.time.Instant + +@Singleton +class UsdtService @Inject constructor( + private val keychain: Keychain, + private val backupRepo: Provider, +) { + private var wallet: UsdtWallet? = null + private var credentialFingerprint: ByteArray? = null + + @Synchronized + fun wallet(requiresNetwork: Boolean = true): UsdtWallet { + if (requiresNetwork && !Env.isUsdtEnabled) throw UsdtException.NotConfigured() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw UsdtException.InvalidCredentials() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val fingerprint = MessageDigest.getInstance("SHA-256") + .digest("${mnemonic.length}:$mnemonic${passphrase.orEmpty()}".encodeToByteArray()) + if (credentialFingerprint?.contentEquals(fingerprint) != true) { + wallet?.close() + wallet = null + } + wallet?.let { return it } + val address = usdtAddress(mnemonic, passphrase) + return UsdtWallet( + address = address, + storagePath = File(Env.bitkitCoreStoragePath(0), "usdt-$address.sqlite").path, + // Local backup access needs a wallet even when network configuration is disabled. + rpcUrl = Env.usdtRpcUrl ?: "http://127.0.0.1:1", + bundlerUrl = Env.usdtBundlerUrl.ifEmpty { "http://127.0.0.1:1" }, + bridgeUrl = Env.usdtBridgesUrl, + backup = object : UsdtBackup { + override suspend fun persist(snapshot: String) { + backupRepo.get().persistWalletBackup(snapshot).getOrElse { throw UsdtException.BackupUnavailable() } + } + }, + ).also { + wallet = it + credentialFingerprint = fingerprint + } + } + + suspend fun depositNetworks(): List { + if (Env.usdtDepositsUrl == null) return emptyList() + return withDeposits { client, _, _ -> client.networks() } + } + + suspend fun prepareDeposit(network: UsdtDepositNetwork, amount: String, locale: Locale = Locale.getDefault()) = + withDeposits { client, mnemonic, passphrase -> + val value = usdtParseAmount(amount.replace(DecimalFormatSymbols.getInstance(locale).decimalSeparator, '.')) + client.receive(network, value, mnemonic, passphrase) + } + + suspend fun depositHistory(offset: UInt) = withDeposits { client, mnemonic, passphrase -> + client.history(offset, mnemonic, passphrase) + } + + suspend fun depositDetail(id: String, offset: UInt) = withDeposits { client, mnemonic, passphrase -> + client.detail(id, offset, mnemonic, passphrase) + } + + suspend fun refundDeposit(id: String, offset: UInt, address: String, network: UsdtDepositNetwork) = + withDeposits { client, mnemonic, passphrase -> + client.requestRefund(id, offset, address, network, mnemonic, passphrase) + } + + private suspend fun withDeposits(block: suspend (UsdtDepositClient, String, String?) -> T): T { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw UsdtException.InvalidCredentials() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val owner = usdtAddress(mnemonic, passphrase) + val client = UsdtDepositClient(owner, requireNotNull(Env.usdtDepositsUrl)) + return try { block(client, mnemonic, passphrase) } finally { client.close() } + } + + val hasStoredWallet: Boolean + get() { + val directory = File(Env.bitkitCoreStoragePath(0)) + if (!directory.exists()) return false + val files = directory.listFiles() ?: throw IOException("Cannot read USDT storage") + return files.any { it.name.startsWith("usdt-") && it.extension == "sqlite" } + } + + @Synchronized + fun backupSnapshot(): String? = if (hasStoredWallet) wallet(requiresNetwork = false).exportBackup() else null + + @Synchronized + fun wipe() { + wallet?.close() + wallet = null + credentialFingerprint = null + val directory = File(Env.bitkitCoreStoragePath(0)) + if (!directory.exists()) return + val files = directory.listFiles() ?: throw IOException("Cannot read USDT storage") + files.filter { it.name.startsWith("usdt-") && it.extension in setOf("sqlite", "sqlite-wal", "sqlite-shm") } + .forEach { if (!it.delete()) throw IOException("Cannot delete USDT storage") } + } + + suspend fun paymentProof(quoteId: String, binding: UsdtPaymentProofBinding) = wallet().createPaymentProof( + quoteId, + binding, + keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw UsdtException.InvalidCredentials(), + keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + ) + + suspend fun send(quote: UsdtQuote, submissionDeadline: Instant? = null) = wallet().sendWithDeadline( + quote.id, + keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw UsdtException.InvalidCredentials(), + keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name), + submissionDeadline?.toString(), + ) +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 8a41a6bcd2..faaefdfe75 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -28,7 +28,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -74,6 +76,7 @@ import to.bitkit.ui.components.BottomSheetOverlayState import to.bitkit.ui.components.DefaultSheetContainerColor import to.bitkit.ui.components.DrawerMenu import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SheetDragHandle import to.bitkit.ui.components.SheetHandlePlacement import to.bitkit.ui.components.SheetHost import to.bitkit.ui.components.TabBar @@ -81,6 +84,7 @@ import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.onboarding.InitializingWalletView import to.bitkit.ui.onboarding.WalletRestoreErrorView import to.bitkit.ui.onboarding.WalletRestoreSuccessView +import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.common.ComingSoonScreen import to.bitkit.ui.screens.contacts.AddContactScreen import to.bitkit.ui.screens.contacts.AddContactViewModel @@ -165,6 +169,8 @@ import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.screens.wallets.receive.ReceiveSheet import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.suggestion.BuyIntroScreen +import to.bitkit.ui.screens.wallets.usdt.UsdtDepositsScreen +import to.bitkit.ui.screens.wallets.usdt.UsdtWalletScreen import to.bitkit.ui.screens.widgets.WidgetsIntroScreen import to.bitkit.ui.settings.BackupSettingsScreen import to.bitkit.ui.settings.BlocktankRegtestScreen @@ -220,6 +226,7 @@ import to.bitkit.ui.sheets.QrScanningSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet import to.bitkit.ui.sheets.UpdateSheet +import to.bitkit.ui.sheets.UsdtIntroSheet import to.bitkit.ui.sheets.WidgetsSheet import to.bitkit.ui.sheets.hardware.HardwareSheet import to.bitkit.ui.theme.Colors @@ -369,6 +376,8 @@ fun ContentView( when (it) { is MainScreenEffect.Navigate -> if (it.clearStack) { navController.navigateTo(it.route) { popUpTo(0) { inclusive = true } } + } else if (it.route is Routes.UsdtPayment) { + navController.navigateToUsdtPayment(it.route) } else { navController.navigateTo(it.route) } @@ -472,6 +481,18 @@ fun ContentView( LocalCurrencies provides currencies, ) { AutoReadClipboardHandler() + if (appViewModel.showUsdtEnablePrompt) { + AppAlertDialog( + title = stringResource(R.string.usdt__enable_title), + text = stringResource(R.string.usdt__enable_description), + confirmText = stringResource(R.string.usdt__enable), + confirmTestTag = "UsdtEnablePromptContinue", + dismissTestTag = "UsdtEnablePromptCancel", + onConfirm = appViewModel::confirmUsdtEnable, + onDismiss = appViewModel::dismissUsdtEnablePrompt, + modifier = Modifier.testTag("UsdtEnablePrompt") + ) + } val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() @@ -486,7 +507,20 @@ fun ContentView( val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() val hwSendViewModel = hiltViewModel() val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle() - val canDismissSheet = currentSheet !is Sheet.Send || hwSendUiState.canLeave + var receiveBlocking by remember(currentSheet) { mutableStateOf(false) } + var receiveUsdt by remember(currentSheet) { mutableStateOf(false) } + val sendState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val usesFlatSheetStyle = when (val sheet = currentSheet) { + is Sheet.TimedSheet -> sheet.type == TimedSheetType.USDT + is Sheet.Receive -> receiveUsdt + is Sheet.Send -> sendState.paykitUsesUsdt + is Sheet.PubkyAuth -> true + else -> false + } + val canDismissSheet = !receiveBlocking && ( + currentSheet !is Sheet.Send || + (hwSendUiState.canLeave && !appViewModel.isPaykitUsdtBusy) + ) val isAcceptingSubscription by appViewModel.isAcceptingSubscription.collectAsStateWithLifecycle() val isRetryingInitialSubscriptionPayment by appViewModel.isRetryingInitialSubscriptionPayment.collectAsStateWithLifecycle() @@ -519,15 +553,16 @@ fun ContentView( !isAcceptingSubscription && !isRetryingInitialSubscriptionPayment && canDismissSheet, + sheetDragHandle = { SheetDragHandle(topPadding = if (usesFlatSheetStyle) 8.dp else 12.dp) }, sheetHandlePlacement = when (currentSheet) { is Sheet.Widgets -> SheetHandlePlacement.ContentOverlay else -> SheetHandlePlacement.ScaffoldSlot }, - sheetContainerColor = when (currentSheet) { - is Sheet.Widgets -> Colors.Gray7 + sheetContainerColor = when { + usesFlatSheetStyle || currentSheet is Sheet.Widgets -> Colors.Gray7 // Meet the top of the subscription sheets' own gradient, so the grabber strip // does not sit a shade darker than the content right below it. - is Sheet.Subscription -> Colors.Gray6 + currentSheet is Sheet.Subscription -> Colors.Gray6 else -> DefaultSheetContainerColor }, sheets = { @@ -552,6 +587,9 @@ fun ContentView( ReceiveSheet( appViewModel = appViewModel, startRoute = sheet.route, + initialTab = sheet.initialTab, + onBlockingChange = { receiveBlocking = it }, + onUsdtSelected = { receiveUsdt = it }, hardwareWalletId = sheet.hardwareWalletId, walletState = walletState, isOffline = connectivityState != ConnectivityState.CONNECTED, @@ -619,6 +657,18 @@ fun ContentView( ) is Sheet.TimedSheet -> { when (sheet.type) { + TimedSheetType.USDT -> UsdtIntroSheet( + onDecline = { + settingsViewModel.setUsdtEnabled( + false + ) { appViewModel.dismissTimedSheet() } + }, + onEnable = { + settingsViewModel.setUsdtEnabled( + true + ) { appViewModel.dismissTimedSheet() } + }, + ) TimedSheetType.APP_UPDATE -> { UpdateSheet(onCancel = { appViewModel.dismissTimedSheet() }) } @@ -1213,6 +1263,25 @@ private fun NavGraphBuilder.home( ) } } + composableWithDefaultTransitions { + UsdtDepositsScreen(onBack = { navController.popBackStack() }) + } + composableWithDefaultTransitions { entry -> + UsdtWalletScreen(onBack = { + navController.popBackStack() + }, initialTransferId = entry.toRoute().transferId) + } + deepLinkableComposable { + UsdtWalletScreen(onBack = { navController.popBackStack() }) + } + composableWithDefaultTransitions { entry -> + val payment = entry.toRoute() + UsdtWalletScreen( + onBack = { navController.popBackStack() }, + initialRecipient = payment.recipient, + initialAmount = payment.amount, + ) + } deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() @@ -1429,8 +1498,13 @@ private fun NavGraphBuilder.contacts( ContactDetailScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onPayContact = { paymentRequest, publicKey, privatePaymentContext -> - appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) + onPayContact = { paymentRequest, publicKey, privatePaymentContext, endpoints -> + appViewModel.openContactPayment( + paymentRequest, + publicKey, + privatePaymentContext, + endpoints = endpoints + ) }, onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, onRequestPayment = { @@ -1473,9 +1547,9 @@ private fun NavGraphBuilder.contacts( popUpTo(Routes.AddContact(publicKey)) { inclusive = true } } }, - onPayContact = { paymentRequest, publicKey -> + onPayContact = { paymentRequest, publicKey, endpoints -> navController.popBackStack() - appViewModel.openContactPayment(paymentRequest, publicKey) + appViewModel.openContactPayment(paymentRequest, publicKey, endpoints = endpoints) }, ) } @@ -2056,6 +2130,10 @@ inline fun NavController.navigateTo( } } +fun NavController.navigateToUsdtPayment(payment: Routes.UsdtPayment) { + navigateTo(payment) { popUpTo { inclusive = true } } +} + fun profileDestination(identityExists: Boolean?, hasSeenIntro: Boolean): Routes.DeepLinkable? = when (identityExists) { true -> Routes.Profile false -> if (hasSeenIntro) Routes.PubkyChoice else Routes.ProfileIntro @@ -2223,6 +2301,18 @@ sealed interface Routes { @Serializable data class HardwareWallet(val walletId: String) : Routes.DeepLinkable + @Serializable + data object UsdtWallet : Routes.DeepLinkable + + @Serializable + data object UsdtDeposits : Routes.InternalOnly + + @Serializable + data class UsdtActivity(val transferId: String) : Routes.InternalOnly + + @Serializable + data class UsdtPayment(val recipient: String, val amount: String) : Routes.InternalOnly + @Serializable data object Settings : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index c17983214f..026af29844 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -254,10 +254,12 @@ class MainActivity : FragmentActivity() { ) val transactionSheetDetails by appViewModel.transactionSheet.collectAsStateWithLifecycle() + val hideBalance by settingsViewModel.hideBalance.collectAsStateWithLifecycle() if (isAuthenticated && transactionSheetDetails != NewTransactionSheetDetails.EMPTY) { NewTransactionSheet( appViewModel = appViewModel, bottomSheetOverlayState = bottomSheetOverlayState, + hideBalance = hideBalance, ) } diff --git a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt index f4b2b7d544..b0f7a983e2 100644 --- a/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt +++ b/app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R @@ -54,6 +55,7 @@ fun BalanceHeaderView( showBitcoinSymbol: Boolean = true, useSwipeToHide: Boolean = true, showEyeIcon: Boolean = false, + isUnavailable: Boolean = false, testTag: String = "", ) { val isPreview = LocalInspectionMode.current @@ -99,11 +101,11 @@ fun BalanceHeaderView( BalanceHeader( isBitcoinPrimary = isBitcoinPrimary, smallRowSymbol = if (isBitcoinPrimary) fiat.symbol else btc.symbol, - smallRowText = if (isBitcoinPrimary) fiat.formatted else btc.value, + smallRowText = if (isUnavailable) "—" else if (isBitcoinPrimary) fiat.formatted else btc.value, smallRowIsSymbolSuffix = if (isBitcoinPrimary) fiat.isSymbolSuffix else false, smallRowModifier = Modifier.testTag("$testTag-secondary"), largeRowPrefix = prefix, - largeRowText = if (isBitcoinPrimary) btc.value else fiat.formatted, + largeRowText = if (isUnavailable) "—" else if (isBitcoinPrimary) btc.value else fiat.formatted, largeRowSymbol = if (isBitcoinPrimary) btc.symbol else fiat.symbol, largeRowIsSymbolSuffix = if (isBitcoinPrimary) false else fiat.isSymbolSuffix, largeRowModifier = Modifier.testTag("$testTag-primary"), @@ -133,6 +135,7 @@ fun BalanceHeader( smallRowIsSymbolSuffix: Boolean = false, smallRowModifier: Modifier = Modifier, largeRowPrefix: String? = null, + prefixSpacing: Dp = 8.dp, largeRowIsSymbolSuffix: Boolean = false, largeRowModifier: Modifier = Modifier, hideBalance: Boolean = false, @@ -229,6 +232,7 @@ fun BalanceHeader( } LargeRow( prefix = state.prefix, + prefixSpacing = prefixSpacing, text = state.text, symbol = state.symbol, showSymbol = state.showSymbol, @@ -298,6 +302,7 @@ fun LargeRow( modifier: Modifier = Modifier, isSymbolSuffix: Boolean = false, hideBalance: Boolean = false, + prefixSpacing: Dp = 8.dp, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -307,7 +312,7 @@ fun LargeRow( SecondaryDisplay( text = prefix, modifier = Modifier - .padding(end = 8.dp) + .padding(end = prefixSpacing) .testTag("MoneySign") ) } diff --git a/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt index b6d2e2365d..cb9c195722 100644 --- a/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt +++ b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import to.bitkit.ui.LocalBottomSheetOverlayState import to.bitkit.ui.scaffold.SheetTopBar @@ -59,6 +60,8 @@ class BottomSheetOverlayState { internal data class BottomSheetOverlayEntry( val key: Any, val modifier: Modifier, + val containerColor: Color?, + val dragHandleTopPadding: Dp, val sheetState: SheetState, val onDismissRequest: () -> Unit, val content: @Composable ColumnScope.() -> Unit, @@ -70,6 +73,8 @@ fun BottomSheet( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor: Color? = null, + dragHandleTopPadding: Dp = 12.dp, content: @Composable ColumnScope.() -> Unit, ) { val overlayState = checkNotNull(LocalBottomSheetOverlayState.current) { @@ -79,6 +84,8 @@ fun BottomSheet( val entry = BottomSheetOverlayEntry( key = sheetKey, modifier = modifier.semantics { testTagsAsResourceId = true }, + containerColor = containerColor, + dragHandleTopPadding = dragHandleTopPadding, sheetState = sheetState, onDismissRequest = onDismissRequest, content = content, @@ -103,8 +110,10 @@ fun BottomSheetOverlayHost( shouldExpand = true, onDismiss = entry.onDismissRequest, scaffoldContainerColor = Color.Transparent, - sheetDragHandle = { ModalSheetDragHandle() }, - sheetContainerColor = Colors.Black, + sheetDragHandle = { + ModalSheetDragHandle(entry.containerColor ?: Colors.White08, entry.dragHandleTopPadding) + }, + sheetContainerColor = entry.containerColor ?: Colors.Black, sheetState = entry.sheetState, sheets = { entry.content(this) @@ -118,14 +127,14 @@ fun BottomSheetOverlayHost( } @Composable -private fun ModalSheetDragHandle() { +private fun ModalSheetDragHandle(color: Color = Colors.White08, topPadding: Dp = 12.dp) { Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxWidth() - .background(color = Colors.White08) + .background(color = color) ) { - SheetDragHandle() + SheetDragHandle(topPadding = topPadding) } } diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt index d32233b387..4488d8c666 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow @@ -112,6 +113,7 @@ fun PrimaryButton( color: Color? = null, enableGradient: Boolean = true, contentColor: Color = Colors.White, + textStyle: TextStyle? = null, ) { val contentPadding = PaddingValues(horizontal = size.primaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val buttonShape = MaterialTheme.shapes.extraLarge @@ -165,7 +167,7 @@ fun PrimaryButton( text?.let { Text( text = text, - style = size.textStyle(), + style = textStyle ?: size.textStyle(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -187,6 +189,7 @@ fun SecondaryButton( enabled: Boolean = true, fullWidth: Boolean = true, hazeState: HazeState? = null, + testTag: String? = null, ) { val contentPadding = PaddingValues(horizontal = size.secondaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val border = size.secondaryBorder(enabled) @@ -225,6 +228,7 @@ fun SecondaryButton( colors = AppButtonDefaults.secondaryColors.copy(contentColor = contentColor), contentPadding = contentPadding, border = border, + modifier = testTag?.let { Modifier.testTag(it) } ?: Modifier ) { if (isLoading) { GradientCircularProgressIndicator( diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index ac452290f6..0558d9c7ce 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -33,11 +33,12 @@ fun MoneyDisplay( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, showSymbol: Boolean = true, + prefix: String = "", ) { val text = rememberMoneyText(sats, showSymbol = showSymbol) text?.let { Display( - text = it.withAccent( + text = "$prefix$it".withAccent( accentStyle = SpanStyle( color = Colors.White64, fontWeight = FontWeight.ExtraBold, diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt index 5ce80f2237..e4d31ccd5b 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt @@ -35,6 +35,7 @@ fun NumberPadActionButton( enabled: Boolean = true, isLoading: Boolean = false, clickable: Boolean = true, + outlined: Boolean = false, @DrawableRes icon: Int? = null, ) { val contentPadding = PaddingValues(horizontal = 8.dp, vertical = 5.dp) @@ -47,9 +48,12 @@ fun NumberPadActionButton( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .requiredHeight(height) - .primaryButtonStyle( - isEnabled = enabled || isLoading, - shape = buttonShape, + .then( + if (outlined) { + Modifier.border(width = 1.dp, color = color, shape = buttonShape) + } else { + Modifier.primaryButtonStyle(isEnabled = enabled || isLoading, shape = buttonShape) + } ) .animateContentSize(animationSpec = tween(durationMillis = 200)) .clickableAlpha(enabled = enabled && clickable && !isLoading, onClick = onClick) diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index ee31a2321a..fba4c3e8d1 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -93,52 +93,16 @@ private fun MoneyAmount( ) VerticalSpacer(12.dp) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - if (!isSymbolSuffix) { - Display( - text = symbol, - fontWeight = FontWeight.ExtraBold, - color = Colors.White64, - modifier = Modifier.padding(end = 6.dp) - ) - } - Display( - text = buildAnnotatedString { - if (value != placeholder) { - withStyle(valueStyle) { - append(value) - } - } - if (placeholder.isNotEmpty() && showPlaceholder) { - withStyle(placeholderStyle) { - append(placeholder) - } - } - }, - modifier = Modifier.weight(1f, fill = false) - ) - if (isSymbolSuffix) { - Display( - text = symbol, - fontWeight = FontWeight.ExtraBold, - color = Colors.White64, - modifier = Modifier.padding(start = 6.dp) - ) - } - if (showEditButton) { - Icon( - painter = painterResource(R.drawable.ic_pencil_simple), - contentDescription = stringResource(R.string.common__edit), - tint = Colors.White, - modifier = Modifier - .padding(start = 5.dp) - .size(24.dp) - ) - } - } + NumberPadAmountText( + showEditButton = showEditButton, + value = value, + placeholder = placeholder, + symbol = symbol, + isSymbolSuffix = isSymbolSuffix, + showPlaceholder = showPlaceholder, + valueStyle = valueStyle, + placeholderStyle = placeholderStyle, + ) } } @@ -242,3 +206,61 @@ private fun PreviewModernValue() { ) } } + +@Composable +fun NumberPadAmountText( + value: String, + symbol: String, + modifier: Modifier = Modifier, + showEditButton: Boolean = false, + placeholder: String = "", + isSymbolSuffix: Boolean = false, + showPlaceholder: Boolean = true, + valueStyle: SpanStyle = SpanStyle(color = Colors.White), + placeholderStyle: SpanStyle = SpanStyle(color = Colors.White50), +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + if (!isSymbolSuffix) { + Display( + text = symbol, + color = Colors.White64, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(end = 6.dp) + ) + } + Display( + text = buildAnnotatedString { + if (value != placeholder) { + withStyle(valueStyle) { + append(value) + } + } + if (placeholder.isNotEmpty() && showPlaceholder) { + withStyle(placeholderStyle) { + append(placeholder) + } + } + }, + modifier = Modifier.weight(1f, fill = false) + ) + if (isSymbolSuffix) { + Display( + text = symbol, + color = Colors.White64, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(start = 6.dp) + ) + } + if (showEditButton) { + Icon( + painter = painterResource(R.drawable.ic_pencil_simple), + contentDescription = stringResource(R.string.common__edit), + tint = Colors.White, + modifier = Modifier.padding(start = 5.dp).size(24.dp) + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/PaykitAcceptedAssets.kt b/app/src/main/java/to/bitkit/ui/components/PaykitAcceptedAssets.kt new file mode 100644 index 0000000000..f71cda0468 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PaykitAcceptedAssets.kt @@ -0,0 +1,52 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import to.bitkit.R +import to.bitkit.models.PaykitAsset +import to.bitkit.repositories.MethodId +import to.bitkit.ui.theme.Colors + +@Composable +fun PaykitAcceptedAssets( + title: String, + availableMethods: ImmutableList, + selectedMethods: ImmutableList, + testTagPrefix: String, + onClickAsset: (PaykitAsset) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Caption13Up(text = title, color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(PaykitAsset.BTC, PaykitAsset.USD).forEach { asset -> + val isUsdt = asset == PaykitAsset.USD + val endpoints = availableMethods.filter { rawValue -> + MethodId.fromRawValue(rawValue)?.let { (it == MethodId.UsdtArbitrum) == isUsdt } == true + } + if (endpoints.isNotEmpty()) { + val selected = endpoints.any { it in selectedMethods } + NumberPadActionButton( + text = if (isUsdt) "USDT" else stringResource(R.string.settings__general__unit_bitcoin), + color = if (!selected) Colors.White32 else if (isUsdt) Colors.Usdt else Colors.Brand, + outlined = true, + onClick = { onClickAsset(asset) }, + modifier = Modifier + .semantics { this.selected = selected } + .testTag("${testTagPrefix}Accept-${if (isUsdt) "usdt" else "bitcoin"}") + ) + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt new file mode 100644 index 0000000000..5251f42b2e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt @@ -0,0 +1,77 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.ui.theme.Colors +import java.math.BigDecimal +import java.text.NumberFormat +import java.util.Locale + +@Composable +fun PaykitAmountDisplay(amount: PaykitAmount, modifier: Modifier = Modifier, prefix: String = "") { + if (amount.asset == PaykitAsset.BTC) { + MoneyDisplay( + sats = amount.atomic.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, + prefix = prefix, + modifier = modifier + ) + } else { + val locale = Locale.getDefault() + val displayAmount = remember(amount, locale) { + if (amount.asset == PaykitAsset.USDT) { + usdtOverviewAmount(amount.atomic, locale) + } else { + usdDisplayAmount(amount.atomic, locale) + } + } + NumberPadAmountText( + value = prefix + displayAmount, + symbol = "$", + modifier = modifier + ) + } +} + +@Composable +fun PaykitAmountCell( + amount: PaykitAmount, + prefix: String = "", + status: String? = null, + showBitcoinSymbol: Boolean = true +) { + if (amount.asset == PaykitAsset.BTC) { + MoneyCell( + sats = amount.atomic.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + prefix = prefix, + showBitcoinSymbol = showBitcoinSymbol, + fiatReplacement = status + ) + } else { + val locale = Locale.getDefault() + val displayAmount = remember(amount, locale) { + if (amount.asset == PaykitAsset.USDT) { + usdtOverviewAmount(amount.atomic, locale) + } else { + usdDisplayAmount(amount.atomic, locale) + } + } + Column(horizontalAlignment = Alignment.End) { + BodyMSB(text = prefix + "$" + displayAmount) + status?.let { BodyS(text = it, color = Colors.White64) } + } + } +} + +internal fun usdDisplayAmount(cents: ULong, locale: Locale = Locale.getDefault()): String { + val format = NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = PaykitAsset.USD.decimals + maximumFractionDigits = PaykitAsset.USD.decimals + } + return format.format(BigDecimal(cents.toString()).movePointLeft(PaykitAsset.USD.decimals)) +} diff --git a/app/src/main/java/to/bitkit/ui/components/PaymentAddressInput.kt b/app/src/main/java/to/bitkit/ui/components/PaymentAddressInput.kt new file mode 100644 index 0000000000..8690cff958 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PaymentAddressInput.kt @@ -0,0 +1,30 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import to.bitkit.ui.theme.AppTextStyles + +@Composable +fun PaymentAddressInput( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, +) { + TextInput( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + minLines = 12, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ), + textStyle = AppTextStyles.Title, + modifier = modifier + ) +} diff --git a/app/src/main/java/to/bitkit/ui/components/PaymentRequestSummary.kt b/app/src/main/java/to/bitkit/ui/components/PaymentRequestSummary.kt new file mode 100644 index 0000000000..c87520d377 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PaymentRequestSummary.kt @@ -0,0 +1,117 @@ +package to.bitkit.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.unit.dp +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.theme.Colors + +@Composable +internal fun PaymentRequestSummary( + profile: PubkyProfile?, + note: String?, + modifier: Modifier = Modifier, + accentColor: Color = Colors.White, +) { + if (profile == null) return + val noteColor = if (note != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.height(IntrinsicSize.Min) + ) { + SendCell( + caption = stringResource(R.string.wallet__send_from), + modifier = Modifier.weight(1f) + ) { + PaymentRequestSummaryValue( + text = profile.name, + icon = R.drawable.ic_user, + iconColor = accentColor, + testTag = "PaymentRequestFrom", + ) + } + SendCell( + caption = stringResource(R.string.wallet__payment_request_for), + modifier = Modifier.weight(1f) + ) { + PaymentRequestSummaryValue( + text = note ?: stringResource(R.string.wallet__payment_request_not_specified), + textColor = noteColor, + icon = R.drawable.ic_note, + iconColor = accentColor, + testTag = "PaymentRequestFor", + ) + } + } +} + +@Composable +private fun PaymentRequestSummaryValue( + text: String, + @DrawableRes icon: Int, + iconColor: Color, + testTag: String, + modifier: Modifier = Modifier, + textColor: Color = MaterialTheme.colorScheme.primary, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(16.dp) + ) + BodySSB( + text = text, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(testTag) + ) + } +} + +@Composable +internal fun PaymentRequestInvoiceNote( + note: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Caption13Up(text = stringResource(R.string.wallet__activity_invoice_note), color = Colors.White64) + VerticalSpacer(8.dp) + ZigzagDivider() + Title( + text = note, + color = Colors.White, + modifier = Modifier + .fillMaxWidth() + .background(Colors.White10) + .padding(24.dp) + .testTag("PaymentRequestInvoiceNote") + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/PaymentReviewIllustration.kt b/app/src/main/java/to/bitkit/ui/components/PaymentReviewIllustration.kt new file mode 100644 index 0000000000..5bc6e588e6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PaymentReviewIllustration.kt @@ -0,0 +1,44 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import to.bitkit.R + +private const val IMAGE_FILL_PERCENTAGE = 0.8f +private const val USDT_IMAGE_SIZE_RATIO = 9f / 7f +private const val SWIPE_ROTATION_DEGREES = 14f + +@Composable +fun PaymentReviewIllustration( + swipeProgress: () -> Float, + modifier: Modifier = Modifier, + isUsdt: Boolean = false, +) { + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxWidth().then(if (isUsdt) Modifier.clipToBounds() else Modifier) + ) { + val imageModifier = if (isUsdt) { + Modifier.requiredSize(minOf(maxHeight, 256.dp) * USDT_IMAGE_SIZE_RATIO) + } else { + Modifier.fillMaxWidth(IMAGE_FILL_PERCENTAGE).padding(bottom = 16.dp) + } + Image( + painter = painterResource(R.drawable.coin_stack_4), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = imageModifier.graphicsLayer { rotationZ = swipeProgress() * SWIPE_ROTATION_DEGREES } + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/SendCell.kt b/app/src/main/java/to/bitkit/ui/components/SendCell.kt index 5a856fc286..e7f55ef349 100644 --- a/app/src/main/java/to/bitkit/ui/components/SendCell.kt +++ b/app/src/main/java/to/bitkit/ui/components/SendCell.kt @@ -6,8 +6,11 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -15,13 +18,15 @@ import to.bitkit.ui.theme.Colors fun SendCell( caption: String, modifier: Modifier = Modifier, + dividerSpacing: Dp = 16.dp, + captionStyle: TextStyle = AppTextStyles.CaptionM, content: @Composable () -> Unit, ) { Column(modifier = modifier.fillMaxWidth()) { - Caption13Up(text = caption, color = Colors.White64) + Caption13Up(text = caption, color = Colors.White64, style = captionStyle) VerticalSpacer(8.dp) content() - VerticalSpacer(16.dp) + VerticalSpacer(dividerSpacing) HorizontalDivider(modifier = Modifier.fillMaxWidth()) } } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt b/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt index 0a8adbce78..af0beff356 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetDragHandle.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -18,12 +19,13 @@ import to.bitkit.ui.theme.Colors @Composable fun SheetDragHandle( modifier: Modifier = Modifier, + topPadding: Dp = 12.dp, ) { Surface( color = Colors.White32, shape = MaterialTheme.shapes.extraLarge, modifier = modifier - .padding(top = 12.dp) + .padding(top = topPadding) ) { Box(Modifier.size(width = 32.dp, height = 4.dp)) } 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 ced2e3015a..e23357e991 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -36,6 +36,7 @@ import to.bitkit.models.SamRockSetupRequest import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.screens.wallets.receive.ReceiveRoute +import to.bitkit.ui.screens.wallets.receive.ReceiveTab import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.PinRoute @@ -71,6 +72,7 @@ sealed interface Sheet { ) : Sheet data class Receive( val route: ReceiveRoute = ReceiveRoute.QR, + val initialTab: ReceiveTab? = null, val hardwareWalletId: String? = null, val presentationId: String = UUID.randomUUID().toString(), ) : Sheet @@ -105,7 +107,8 @@ sealed interface Sheet { /**@param priority Priority levels for timed sheets (higher number = higher priority)*/ enum class TimedSheetType(val priority: Int) { - APP_UPDATE(priority = 5), + APP_UPDATE(priority = 6), + USDT(priority = 5), BACKUP(priority = 4), NOTIFICATIONS(priority = 3), QUICK_PAY(priority = 2), diff --git a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt index 582c0b926e..a693d8740f 100644 --- a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt +++ b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt @@ -76,12 +76,12 @@ fun SwipeToConfirm( val trailColor = remember(color) { color.copy(alpha = 0.24f) } var swiperWidth by remember { mutableFloatStateOf(0f) } - val maxPanX = if (swiperWidth == 0f) 1f else swiperWidth - with(LocalDensity.current) { CircleSize.toPx() } + val maxPanX = (swiperWidth - with(LocalDensity.current) { CircleSize.toPx() }).coerceAtLeast(1f) val panX = remember { Animatable(0f) } val startedConfirmed = remember { confirmed } val loadingOpacity = remember { Animatable(0f) } - val contentAlpha = if (enabled || loading) 1f else 0.5f + val contentAlpha = if (enabled || loading || confirmed) 1f else 0.5f LaunchedEffect(loading) { loadingOpacity.animateTo( diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index e3d4146ca6..52856df36f 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -48,12 +49,14 @@ fun Display( modifier: Modifier = Modifier, fontSize: TextUnit = 44.sp, color: Color = MaterialTheme.colorScheme.primary, + letterSpacing: TextUnit = (-1).sp, ) { Text( text = text.toUpperCase(), style = AppTextStyles.Display.merge( fontSize = fontSize, lineHeight = fontSize, + letterSpacing = letterSpacing, color = color, ), modifier = modifier @@ -139,12 +142,13 @@ fun Title( maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Ellipsis, textAlign: TextAlign = TextAlign.Unspecified, + style: TextStyle = AppTextStyles.Title, ) { Text( text = text, maxLines = maxLines, overflow = overflow, - style = AppTextStyles.Title.merge( + style = style.merge( color = color, textAlign = textAlign, ), @@ -182,9 +186,13 @@ fun BodyM( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, + letterSpacing: TextUnit = TextUnit.Unspecified, + style: TextStyle = AppTextStyles.BodyM, ) { BodyM( text = AnnotatedString(text), + style = style, + letterSpacing = letterSpacing, color = color, modifier = modifier, textAlign = textAlign, @@ -203,10 +211,13 @@ fun BodyM( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, + letterSpacing: TextUnit = TextUnit.Unspecified, + style: TextStyle = AppTextStyles.BodyM, ) { Text( text = text, - style = AppTextStyles.BodyM.merge( + style = style.merge( + letterSpacing = letterSpacing, color = color, textAlign = textAlign, ), @@ -225,9 +236,13 @@ fun BodyMSB( maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, textAlign: TextAlign = TextAlign.Start, + letterSpacing: TextUnit = TextUnit.Unspecified, + style: TextStyle = AppTextStyles.BodyMSB, ) { BodyMSB( text = AnnotatedString(text), + style = style, + letterSpacing = letterSpacing, color = color, maxLines = maxLines, overflow = overflow, @@ -244,10 +259,13 @@ fun BodyMSB( maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, textAlign: TextAlign = TextAlign.Start, + letterSpacing: TextUnit = TextUnit.Unspecified, + style: TextStyle = AppTextStyles.BodyMSB, ) { Text( text = text, - style = AppTextStyles.BodyMSB.merge( + style = style.merge( + letterSpacing = letterSpacing, color = color, textAlign = textAlign, ), @@ -286,9 +304,11 @@ fun BodyS( textAlign: TextAlign = TextAlign.Start, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, + letterSpacing: TextUnit = TextUnit.Unspecified, ) { BodyS( text = AnnotatedString(text), + letterSpacing = letterSpacing, color = color, textAlign = textAlign, maxLines = maxLines, @@ -305,10 +325,12 @@ fun BodyS( textAlign: TextAlign = TextAlign.Start, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, + letterSpacing: TextUnit = TextUnit.Unspecified, ) { Text( text = text, style = AppTextStyles.BodyS.merge( + letterSpacing = letterSpacing, color = color, textAlign = textAlign, ), @@ -325,9 +347,11 @@ fun BodySSB( color: Color = MaterialTheme.colorScheme.primary, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, + style: TextStyle = AppTextStyles.BodySSB, ) { BodySSB( text = AnnotatedString(text), + style = style, color = color, modifier = modifier, maxLines = maxLines, @@ -342,10 +366,11 @@ fun BodySSB( color: Color = MaterialTheme.colorScheme.primary, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, + style: TextStyle = AppTextStyles.BodySSB, ) { Text( text = text, - style = AppTextStyles.BodySSB.merge( + style = style.merge( color = color, ), modifier = modifier, @@ -442,14 +467,20 @@ fun CaptionB( textAlign: TextAlign = TextAlign.Start, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, + letterSpacing: TextUnit = TextUnit.Unspecified, + lineHeight: TextUnit = TextUnit.Unspecified, + style: TextStyle = AppTextStyles.CaptionB, ) { CaptionB( text = AnnotatedString(text), + style = style, modifier = modifier, color = color, textAlign = textAlign, maxLines = maxLines, overflow = overflow, + letterSpacing = letterSpacing, + lineHeight = lineHeight, ) } @@ -461,10 +492,15 @@ fun CaptionB( textAlign: TextAlign = TextAlign.Start, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, + letterSpacing: TextUnit = TextUnit.Unspecified, + lineHeight: TextUnit = TextUnit.Unspecified, + style: TextStyle = AppTextStyles.CaptionB, ) { Text( text = text, - style = AppTextStyles.CaptionB.merge( + style = style.merge( + letterSpacing = letterSpacing, + lineHeight = lineHeight, color = color, textAlign = textAlign, ), @@ -482,10 +518,11 @@ fun Caption13Up( textAlign: TextAlign = TextAlign.Start, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, + style: TextStyle = AppTextStyles.CaptionM, ) { Text( text = text.uppercase(), - style = AppTextStyles.CaptionM.merge( + style = style.merge( color = color, textAlign = textAlign, ), diff --git a/app/src/main/java/to/bitkit/ui/components/UsdtAmountHeader.kt b/app/src/main/java/to/bitkit/ui/components/UsdtAmountHeader.kt new file mode 100644 index 0000000000..c621fbf162 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/UsdtAmountHeader.kt @@ -0,0 +1,76 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import to.bitkit.ext.nowMillis +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitExchangeRate +import to.bitkit.models.formatMoney +import to.bitkit.repositories.paykitRate +import to.bitkit.ui.LocalCurrencies +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +@Composable +internal fun UsdtAmountHeader( + amount: String, + modifier: Modifier = Modifier, + displayAmount: String? = null, + hideBalance: Boolean = false, + prefix: String? = null, + onToggleHide: (() -> Unit)? = null, + onClick: () -> Unit = {}, +) { + val rate = LocalCurrencies.current.paykitRate + val sats = remember(amount, rate) { usdtDisplaySats(amount, rate) } + val btc = sats?.formatMoney(LocalCurrencies.current.displayUnit) + BalanceHeader( + isBitcoinPrimary = false, + smallRowText = btc ?: "USDT", + smallRowSymbol = if (btc != null) BITCOIN_SYMBOL else null, + largeRowText = displayAmount ?: amount, + largeRowSymbol = "$", + largeRowPrefix = prefix, + showSymbol = true, + hideBalance = hideBalance, + onClick = onClick, + isSwipeToHideEnabled = onToggleHide != null, + onToggleHideBalance = { onToggleHide?.invoke() }, + modifier = modifier.fillMaxWidth() + ) +} + +private const val USDT_CENT = 10_000uL + +internal fun usdtOverviewAmount(amount: ULong, locale: Locale = Locale.getDefault()): String { + val format = NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 2 + maximumFractionDigits = 2 + roundingMode = RoundingMode.HALF_UP + } + if (amount > 0uL && amount < USDT_CENT) return "<" + format.format(BigDecimal("0.01")) + return format.format(BigDecimal(amount.toString()).movePointLeft(PaykitAsset.USDT.decimals)) +} + +internal fun usdtExactAmount(amount: ULong, locale: Locale = Locale.getDefault()): String = + NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 2 + maximumFractionDigits = PaykitAsset.USDT.decimals + }.format(BigDecimal(amount.toString()).movePointLeft(PaykitAsset.USDT.decimals)) + +internal fun usdtDisplaySats(amount: String, rate: PaykitExchangeRate?, now: Long = nowMillis()): Long? { + val dollars = amount.toBigDecimalOrNull()?.takeIf { it.signum() >= 0 } ?: return null + val price = runCatching { rate?.value(now) }.getOrNull() ?: return null + return runCatching { + dollars.divide( + price, + PaykitAsset.BTC.decimals, + RoundingMode.DOWN + ).movePointRight(PaykitAsset.BTC.decimals).longValueExact() + }.getOrNull() +} diff --git a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt index 70020ecac2..6adde6541c 100644 --- a/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt +++ b/app/src/main/java/to/bitkit/ui/components/WalletBalanceView.kt @@ -100,67 +100,52 @@ private fun RowScope.Content( hideBalance: Boolean, titleTrailing: @Composable RowScope.() -> Unit = {}, ) { - Column( - modifier = Modifier - .weight(1f) - .then(modifier) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text13Up( - text = title, - color = Colors.White64, - maxLines = 1, - modifier = Modifier.weight(1f, fill = false) - ) - titleTrailing() - } - VerticalSpacer(8.dp) - - converted?.let { converted -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + WalletBalanceContent( + title = title, + icon = { + if (converted != null) { Icon( painter = icon, contentDescription = title, tint = Color.Unspecified, - modifier = Modifier - .padding(end = 4.dp) - .size(24.dp) + modifier = Modifier.padding(end = 4.dp).size(24.dp) ) - - if (primaryDisplay == PrimaryDisplay.BITCOIN) { - val btcComponents = converted.bitcoinDisplay(displayUnit) - AnimatedContent( - targetState = hideBalance, - transitionSpec = { BalanceAnimations.walletBalanceTransition }, - label = "bitcoinBalanceAnimation" - ) { isHidden -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else btcComponents.value) - } + } + }, + titleTrailing = titleTrailing, + modifier = modifier, + ) { + converted?.let { converted -> + if (primaryDisplay == PrimaryDisplay.BITCOIN) { + val btcComponents = converted.bitcoinDisplay(displayUnit) + AnimatedContent( + targetState = hideBalance, + transitionSpec = { BalanceAnimations.walletBalanceTransition }, + label = "bitcoinBalanceAnimation" + ) { isHidden -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else btcComponents.value) } - } else { - AnimatedContent( - targetState = hideBalance, - transitionSpec = { BalanceAnimations.walletBalanceTransition }, - label = "fiatBalanceAnimation" - ) { isHidden -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (converted.isSymbolSuffix) { - BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else converted.formatted) - BodyMSB(text = converted.symbol) - } else { - BodyMSB(text = converted.symbol) - BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else converted.formatted) - } + } + } else { + AnimatedContent( + targetState = hideBalance, + transitionSpec = { BalanceAnimations.walletBalanceTransition }, + label = "fiatBalanceAnimation" + ) { isHidden -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (converted.isSymbolSuffix) { + BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else converted.formatted) + BodyMSB(text = converted.symbol) + } else { + BodyMSB(text = converted.symbol) + BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else converted.formatted) } } } @@ -169,6 +154,27 @@ private fun RowScope.Content( } } +@Composable +fun RowScope.WalletBalanceContent( + title: String, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + titleTrailing: @Composable RowScope.() -> Unit = {}, + amount: @Composable () -> Unit, +) { + Column(modifier = Modifier.weight(1f).then(modifier)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text13Up(text = title, color = Colors.White64, maxLines = 1, modifier = Modifier.weight(1f, fill = false)) + titleTrailing() + } + VerticalSpacer(8.dp) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + icon() + amount() + } + } +} + @Preview @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt b/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt index d27a48827f..deece27b01 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt @@ -34,6 +34,8 @@ fun AppAlertDialog( dismissText: String = stringResource(R.string.common__dialog_cancel), confirmTextColor: Color = Color.Unspecified, dismissTextColor: Color = Colors.White64, + confirmTestTag: String = "DialogConfirm", + dismissTestTag: String = "DialogCancel", onDismissRequest: () -> Unit = onDismiss, properties: DialogProperties = DialogProperties( dismissOnClickOutside = false, @@ -49,6 +51,8 @@ fun AppAlertDialog( dismissText = dismissText, confirmTextColor = confirmTextColor, dismissTextColor = dismissTextColor, + confirmTestTag = confirmTestTag, + dismissTestTag = dismissTestTag, onDismissRequest = onDismissRequest, properties = properties, textContent = { BodyM(text = text, color = Colors.White64) }, @@ -65,6 +69,8 @@ fun AppAlertDialog( dismissText: String = stringResource(R.string.common__dialog_cancel), confirmTextColor: Color = Color.Unspecified, dismissTextColor: Color = Colors.White64, + confirmTestTag: String = "DialogConfirm", + dismissTestTag: String = "DialogCancel", onDismissRequest: () -> Unit = onDismiss, properties: DialogProperties = DialogProperties( dismissOnClickOutside = false, @@ -78,7 +84,7 @@ fun AppAlertDialog( confirmButton = { TextButton( onClick = rememberDebouncedClick(onClick = onConfirm), - modifier = Modifier.testTag("DialogConfirm") + modifier = Modifier.testTag(confirmTestTag) ) { BodyMSB(text = confirmText, color = confirmTextColor) } @@ -86,7 +92,7 @@ fun AppAlertDialog( dismissButton = { TextButton( onClick = rememberDebouncedClick(onClick = onDismiss), - modifier = Modifier.testTag("DialogCancel") + modifier = Modifier.testTag(dismissTestTag) ) { BodyMSB(text = dismissText, color = dismissTextColor) } diff --git a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt index ab394ce987..5a0e11f285 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -27,6 +28,7 @@ import to.bitkit.R import to.bitkit.ui.LocalDrawerState import to.bitkit.ui.components.Title import to.bitkit.ui.shared.modifiers.rememberDebouncedClick +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -38,6 +40,7 @@ fun AppTopBar( modifier: Modifier = Modifier, @DrawableRes icon: Int? = null, actions: @Composable (RowScope.() -> Unit) = {}, + titleStyle: TextStyle = AppTextStyles.Title, ) { CenterAlignedTopAppBar( navigationIcon = { @@ -59,7 +62,7 @@ fun AppTopBar( .size(32.dp) ) } - Title(text = text, maxLines = 1) + Title(text = text, maxLines = 1, style = titleStyle) } } }, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index 63ae007ec6..1fd030fb7b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt @@ -57,6 +57,7 @@ import to.bitkit.ext.getClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.Endpoint import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheet @@ -240,7 +241,7 @@ fun AddContactScreen( viewModel: AddContactViewModel, onBackClick: () -> Unit, onContactSaved: (String) -> Unit, - onPayContact: (String, String) -> Unit, + onPayContact: (String, String, List) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -248,7 +249,7 @@ fun AddContactScreen( viewModel.effects.collect { when (it) { is AddContactEffect.ContactSaved -> onContactSaved(it.publicKey) - is AddContactEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) + is AddContactEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey, it.endpoints) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index d4818aafbb..eb93d9c708 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -18,6 +18,7 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult @@ -121,7 +122,9 @@ class AddContactViewModel @Inject constructor( .onSuccess { result -> when (result) { is PublicPaykitPaymentResult.Opened -> - _effects.emit(AddContactEffect.OpenPayment(result.paymentRequest, profile.publicKey)) + _effects.emit( + AddContactEffect.OpenPayment(result.paymentRequest, profile.publicKey, result.endpoints) + ) PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> @@ -185,5 +188,9 @@ data class AddContactUiState( sealed interface AddContactEffect { data class ContactSaved(val publicKey: String) : AddContactEffect - data class OpenPayment(val paymentRequest: String, val publicKey: String) : AddContactEffect + data class OpenPayment( + val paymentRequest: String, + val publicKey: String, + val endpoints: List = emptyList() + ) : AddContactEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index d861534525..45f978c674 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.Job import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.ActionButton @@ -70,7 +71,7 @@ import to.bitkit.ui.utils.withAccent fun ContactDetailScreen( viewModel: ContactDetailViewModel, onBackClick: () -> Unit, - onPayContact: (String, String, PrivatePaykitPaymentContext?) -> Job?, + onPayContact: (String, String, PrivatePaykitPaymentContext?, List) -> Job?, onActivityClick: (String) -> Unit, onRequestPayment: (PaykitPaymentRequestTarget) -> Unit = {}, showDeleteAction: Boolean = false, @@ -88,7 +89,7 @@ fun ContactDetailScreen( viewModel.effects.collect { when (it) { is ContactDetailEffect.OpenPayment -> { - val scanJob = onPayContact(it.paymentRequest, it.publicKey, it.privatePaymentContext) + val scanJob = onPayContact(it.paymentRequest, it.publicKey, it.privatePaymentContext, it.endpoints) viewModel.onPaymentOpening(scanJob) } ContactDetailEffect.ContactDeleted -> onContactDeleted() diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 43903caf0a..73d0848a30 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -32,6 +32,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PrivatePaykitPaymentContext @@ -240,6 +241,7 @@ class ContactDetailViewModel @Inject constructor( it.paymentRequest, publicKey, it.privatePaymentContext, + it.endpoints, ) ) } @@ -420,6 +422,7 @@ sealed interface ContactDetailEffect { val paymentRequest: String, val publicKey: String, val privatePaymentContext: PrivatePaykitPaymentContext?, + val endpoints: List = emptyList(), ) : ContactDetailEffect data object ContactDeleted : ContactDetailEffect diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index ab04f43d03..c1856520f9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -17,12 +18,14 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,13 +47,18 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.ext.getClipboardText +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAmountConversion +import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.paykitRate import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB @@ -61,24 +69,27 @@ import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.MoneyCell -import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.NumberPadAmountText import to.bitkit.ui.components.NumberPadTextField +import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.components.PaykitAcceptedAssets +import to.bitkit.ui.components.PaykitAmountCell +import to.bitkit.ui.components.PaykitAmountDisplay import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.PubkyContactRow import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha 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.ui.utils.removeAccentTags +import to.bitkit.ui.utils.NumberPadInputHandler import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel @@ -131,15 +142,29 @@ internal fun PaymentRequestAmountContent( contact: PubkyProfile?, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, + allowedAssets: ImmutableList = persistentListOf(PaykitAsset.BTC, PaykitAsset.USD), + conversion: PaykitAmountConversion = remember { PaykitAmountConversion() }, ) { val currencies = LocalCurrencies.current val amountState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(initialDraft.amountSats) { - amountInputViewModel.setSats( - initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - currencies, - ) + var asset by remember(initialDraft.amount.asset) { mutableStateOf(initialDraft.amount.asset) } + var dollars by remember(initialDraft.amount) { + mutableStateOf(if (initialDraft.amount.asset != PaykitAsset.BTC) initialDraft.amount.value else "") + } + var conversionError by remember { mutableStateOf(false) } + val entered = if (asset == PaykitAsset.BTC) { + PaykitAmount(asset, amountState.sats.toULong()) + } else { + runCatching { PaykitAmount.parseInput(asset, dollars) }.getOrNull() + } + LaunchedEffect(initialDraft.amount) { + if (initialDraft.amount.asset == PaykitAsset.BTC) { + amountInputViewModel.setSats( + initialDraft.amount.atomic.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + currencies + ) + } } Column( @@ -167,34 +192,78 @@ internal fun PaymentRequestAmountContent( Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { VerticalSpacer(16.dp) - NumberPadTextField( - viewModel = amountInputViewModel, - modifier = Modifier - .fillMaxWidth() - .testTag("PaymentRequestAmountField"), + if (asset == PaykitAsset.BTC) { + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountField") + ) + } else { + NumberPadAmountText( + value = dollars.ifEmpty { "0" }, + symbol = "$", + modifier = Modifier.testTag("PaymentRequestAmountField") + ) + } + VerticalSpacer(16.dp) + PaymentRequestAssetSelector( + asset = asset, + amount = entered, + allowedAssets = allowedAssets, + conversion = conversion, + onConverted = { + asset = it.asset + if (it.asset == PaykitAsset.BTC) { + amountInputViewModel.setSats(it.atomic.toLong(), currencies) + } else { + dollars = if (it.atomic == 0uL) "" else it.value + } + conversionError = false + }, + onUnavailable = { conversionError = true }, ) - FillHeight(min = 12.dp) - Row(modifier = Modifier.fillMaxWidth()) { - FillWidth() - UnitButton( - onClick = { amountInputViewModel.switchUnit(currencies) }, - color = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestAmountUnit"), + if (conversionError) { + BodyS( + text = stringResource(R.string.wallet__payment_request_rate_unavailable), + color = Colors.White64 ) } + FillHeight(min = 12.dp) + if (asset == PaykitAsset.BTC) { + Row(modifier = Modifier.fillMaxWidth()) { + FillWidth() + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + color = Colors.Brand, + modifier = Modifier.testTag("PaymentRequestAmountUnit") + ) + } + } VerticalSpacer(16.dp) HorizontalDivider(color = Colors.White10) - NumberPad( - viewModel = amountInputViewModel, - currencies = currencies, - availableHeight = availableHeight, - modifier = Modifier.testTag("PaymentRequestNumberPad"), - ) + if (asset == PaykitAsset.BTC) { + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + availableHeight = availableHeight, + modifier = Modifier.testTag("PaymentRequestNumberPad") + ) + } else { + NumberPad( + onPress = { + dollars = NumberPadInputHandler.handleInput( + it, dollars, MAX_USD_INPUT_LENGTH, PaykitAsset.USD.decimals + ) + }, + type = NumberPadType.DECIMAL, + availableHeight = availableHeight, + modifier = Modifier.testTag("PaymentRequestNumberPad") + ) + } PrimaryButton( text = stringResource(R.string.common__continue), - enabled = amountState.sats > 0, + enabled = (entered?.atomic ?: 0uL) > 0uL, onClick = { - onContinue(initialDraft.copy(amountSats = amountState.sats.toULong())) + entered?.let { onContinue(initialDraft.copy(amount = it)) } }, modifier = Modifier.testTag("PaymentRequestAmountContinue"), ) @@ -204,6 +273,42 @@ internal fun PaymentRequestAmountContent( } } +private const val MAX_USD_INPUT_LENGTH = 18 + +@Composable +private fun PaymentRequestAssetSelector( + asset: PaykitAsset, + amount: PaykitAmount?, + onConverted: (PaykitAmount) -> Unit, + onUnavailable: () -> Unit, + allowedAssets: ImmutableList, + conversion: PaykitAmountConversion, +) { + val currencies = LocalCurrencies.current + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + allowedAssets.forEach { option -> + NumberPadActionButton( + text = option.code.uppercase(), + outlined = asset != option, + color = if (option == PaykitAsset.BTC) Colors.Brand else Colors.Usdt, + modifier = Modifier.testTag("PaymentRequestAsset${option.name}"), + onClick = { + if (option != asset) { + runCatching { + conversion.convert( + amount, + option, + currencies.paykitRate, + Clock.System.now().toEpochMilliseconds() + ) + }.onSuccess(onConverted).onFailure { onUnavailable() } + } + }, + ) + } + } +} + @Composable fun PaymentRequestDetailsScreen( appViewModel: AppViewModel, @@ -215,11 +320,13 @@ fun PaymentRequestDetailsScreen( ) { val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val receivingMethods by appViewModel.paykitReceivingMethods.collectAsStateWithLifecycle() val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) } ?: PubkyProfile.placeholder(target.publicKey) PaymentRequestDetailsContent( initialDraft = draft, + availableMethods = receivingMethods.toImmutableList(), contact = contact, isCreating = isCreating, onBack = onBack, @@ -231,6 +338,7 @@ fun PaymentRequestDetailsScreen( @Composable internal fun PaymentRequestDetailsContent( initialDraft: PaykitPaymentRequestDraft, + availableMethods: ImmutableList = persistentListOf(), contact: PubkyProfile, isCreating: Boolean, onBack: () -> Unit, @@ -238,11 +346,16 @@ internal fun PaymentRequestDetailsContent( onSend: (PaykitPaymentRequestDraft) -> Unit, modifier: Modifier = Modifier, ) { + var acceptedMethods by remember(initialDraft.acceptedPaymentEndpointIdentifiers) { + mutableStateOf(initialDraft.acceptedPaymentEndpointIdentifiers) + } + val selectedMethods = acceptedMethods ?: availableMethods var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } var expiration by remember(initialDraft.expiresAt) { mutableStateOf(PaymentRequestExpiration.from(initialDraft.expiresAt, Clock.System.now())) } fun updatedDraft(trimNote: Boolean = false) = initialDraft.copy( + acceptedPaymentEndpointIdentifiers = selectedMethods, note = if (trimNote) note.trim() else note, expiresAt = Clock.System.now() + expiration.duration, ) @@ -250,7 +363,7 @@ internal fun PaymentRequestDetailsContent( Column( modifier = modifier .fillMaxSize() - .gradientBackground() + .background(Colors.Gray7) .navigationBarsPadding() .padding(horizontal = 16.dp) .testTag("PaymentRequestDetails") @@ -259,101 +372,109 @@ internal fun PaymentRequestDetailsContent( titleText = stringResource(R.string.wallet__payment_request), onBack = onBack, ) - rememberMoneyText( - sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - reversed = true, - showSymbol = true, - )?.let { - Caption13Up(text = it.removeAccentTags(), color = Colors.White64) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - MoneyDisplay( - sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - showSymbol = true, - ) - FillWidth() - IconButton( - onClick = { onEditAmount(updatedDraft()) }, - modifier = Modifier - .size(48.dp) - .testTag("PaymentRequestEditAmount"), + Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { + VerticalSpacer(8.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) + VerticalSpacer(16.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.clickableAlpha { onEditAmount(updatedDraft()) }.testTag("PaymentRequestEditAmount") ) { + PaykitAmountDisplay( + amount = initialDraft.amount, + modifier = Modifier.weight(1f, fill = false).width(IntrinsicSize.Max) + ) Icon( painter = painterResource(R.drawable.ic_pencil_simple), contentDescription = stringResource(R.string.common__edit), tint = Colors.White, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) } - } - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) - VerticalSpacer(8.dp) - TextInput( - value = note, - onValueChange = { note = it.take(256) }, - placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), - maxLines = 2, - modifier = Modifier - .fillMaxWidth() - .testTag("PaymentRequestNote"), - ) - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) - VerticalSpacer(8.dp) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .background(Colors.Gray6, RoundedCornerShape(16.dp)) - .padding(16.dp), - ) { - PubkyContactAvatar(profile = contact, size = 40.dp) - Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { - BodyMSB(text = contact.name, maxLines = 1) - BodyS( - text = note.ifBlank { stringResource(R.string.wallet__payment_request) }, - color = Colors.White64, - maxLines = 1, - ) - } - MoneyCell(sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) - } - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) - VerticalSpacer(8.dp) - Row(modifier = Modifier.fillMaxWidth()) { - PaymentRequestExpiration.entries.forEach { option -> - val isSelected = option == expiration - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .clickableAlpha { expiration = option } - .testTag("PaymentRequestExpiry${option.name}"), - ) { - BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) - VerticalSpacer(8.dp) - HorizontalDivider( - thickness = 2.dp, - color = if (isSelected) Colors.White else Colors.White16, + VerticalSpacer(32.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = note, + onValueChange = { note = it.take(256) }, + placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestNote"), + ) + VerticalSpacer(32.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB(text = contact.name, maxLines = 1) + BodyS( + text = note.ifBlank { stringResource(R.string.wallet__payment_request) }, + color = Colors.White64, + maxLines = 1, ) } + PaykitAmountCell(initialDraft.amount) } + VerticalSpacer(32.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + PaymentRequestExpiration.entries.forEach { option -> + val isSelected = option == expiration + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .clickableAlpha { expiration = option } + .testTag("PaymentRequestExpiry${option.name}"), + ) { + VerticalSpacer(8.dp) + CaptionB(text = option.title(), color = if (isSelected) Colors.White else Colors.White50) + VerticalSpacer(8.dp) + HorizontalDivider( + thickness = 2.dp, + color = if (isSelected) Colors.White else Colors.White50, + ) + } + } + } + VerticalSpacer(32.dp) + PaykitAcceptedAssets( + title = stringResource(R.string.wallet__payment_request_accepted_methods), + availableMethods = availableMethods, + selectedMethods = selectedMethods.toImmutableList(), + testTagPrefix = "PaymentRequest", + onClickAsset = { asset -> + val endpoints = availableMethods.filter { + (it == MethodId.UsdtArbitrum.rawValue) == (asset == PaykitAsset.USD) + } + acceptedMethods = if (endpoints.any { it in selectedMethods }) { + selectedMethods - endpoints.toSet() + } else { + selectedMethods + endpoints.filter { it !in selectedMethods } + } + }, + ) + VerticalSpacer(32.dp) } - FillHeight() PrimaryButton( text = stringResource(R.string.wallet__payment_request_send_request), - enabled = !isCreating, + enabled = !isCreating && selectedMethods.isNotEmpty(), isLoading = isCreating, onClick = { onSend(updatedDraft(trimNote = true)) }, icon = { Icon( - painter = painterResource(R.drawable.ic_sent), + painter = painterResource(R.drawable.ic_paper_plane_tilt), contentDescription = null, modifier = Modifier.size(16.dp), ) @@ -590,7 +711,7 @@ internal fun PaymentRequestExpiration.title(): String = stringResource( ) private val previewDraft = PaykitPaymentRequestDraft( - amountSats = 25_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 25_000uL), note = "Dinner", expiresAt = Instant.parse("2027-01-15T09:00:00Z"), ) @@ -602,8 +723,8 @@ private val previewTarget = PaykitPaymentRequestTarget( private val previewCreatedRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = previewTarget.publicKey, - amountValue = "0.00025", - amountSats = previewDraft.amountSats, + amount = previewDraft.amount, + paymentReference = "preview", note = previewDraft.note, createdAt = Instant.parse("2027-01-15T08:00:00Z"), expiresAt = previewDraft.expiresAt, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt index 46411d7fa6..abd80bbfcd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -40,18 +41,19 @@ import to.bitkit.R import to.bitkit.ext.UiDateStyle import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitUsdtReceipt import to.bitkit.ui.components.AddTagButton import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.PaykitAmountDisplay import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.SecondaryButton @@ -59,14 +61,11 @@ import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.ZigzagDivider -import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.screens.wallets.activity.components.CircularIcon import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.uiDateText -import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AppViewModel import kotlin.time.ExperimentalTime @@ -77,6 +76,7 @@ fun IncomingPaymentRequestDetailsScreen( onBack: () -> Unit, ) { val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val receipts by appViewModel.paykitUsdtPayments.receipts.collectAsStateWithLifecycle() val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val requestedPaymentRequestId by appViewModel.requestedPaymentRequestId.collectAsStateWithLifecycle() @@ -89,6 +89,7 @@ fun IncomingPaymentRequestDetailsScreen( IncomingPaymentRequestDetailsContent( request = request, + receipt = request?.let { paymentRequest -> receipts.lastOrNull { it.matches(paymentRequest) } }, contact = contact, isPending = isPending, isPreparing = requestedPaymentRequestId == id, @@ -101,6 +102,7 @@ fun IncomingPaymentRequestDetailsScreen( @Composable internal fun IncomingPaymentRequestDetailsContent( request: PaykitPaymentRequest?, + receipt: PaykitUsdtReceipt? = null, contact: PubkyProfile?, isPending: Boolean, isPreparing: Boolean, @@ -143,31 +145,25 @@ internal fun IncomingPaymentRequestDetailsContent( .padding(horizontal = 16.dp), ) { VerticalSpacer(16.dp) - rememberMoneyText( - sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - reversed = true, - showSymbol = true, - )?.let { - Caption13Up(text = it.removeAccentTags(), color = Colors.White64) - } - rememberMoneyText( - sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - )?.let { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Display( - text = request.detailsAmountText(it).withAccent(accentColor = Colors.White64), - modifier = Modifier.testTag("PaymentRequestDetailsAmount") - ) - FillWidth() - if (request.hasPaymentEvidence) { - PaymentRequestDetailsIcon(request) - } else { - PubkyContactAvatar(profile = contact, size = 48.dp) - } + Caption13Up( + text = stringResource(R.string.wallet__payment_request_requested_amount), + color = Colors.White64 + ) + VerticalSpacer(8.dp) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + PaykitAmountDisplay( + request.amount, + prefix = request.detailsAmountPrefix(), + modifier = Modifier.weight(1f) + ) + if (request.hasPaymentEvidence) { + PaymentRequestDetailsIcon(request) + } else { + PubkyContactAvatar(profile = contact, size = 48.dp) } } BodyM( - text = paymentRequestStatus(request, isPending = isPending), + text = paymentRequestStatus(request, isPending = isPending, receipt = receipt), color = Colors.White64, modifier = Modifier.testTag("PaymentRequestDetailsStatus") ) @@ -178,6 +174,7 @@ internal fun IncomingPaymentRequestDetailsContent( value = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) } ?: stringResource(R.string.wallet__payment_request_status_unavailable), iconRes = R.drawable.ic_calendar, + iconColor = request.dateTimeIconColor, modifier = Modifier.weight(1f) ) RequestDetailCell( @@ -185,6 +182,7 @@ internal fun IncomingPaymentRequestDetailsContent( value = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.TIME) } ?: stringResource(R.string.wallet__payment_request_status_unavailable), iconRes = R.drawable.ic_clock, + iconColor = request.dateTimeIconColor, modifier = Modifier.weight(1f) ) } @@ -317,6 +315,9 @@ internal fun PaykitPaymentRequest.detailsAmountPrefix(): String = when { internal fun PaykitPaymentRequest.detailsAmountText(amount: String): String = "${detailsAmountPrefix()} $amount".trimStart() +private val PaykitPaymentRequest.dateTimeIconColor + get() = if (paymentProofKind == PaykitPaymentProofKind.Usdt) Colors.Usdt else Colors.Purple + @Composable private fun PaymentRequestDetailsIcon(request: PaykitPaymentRequest) { val isIncomingRequest = request.direction == PaykitPaymentRequestDirection.Incoming @@ -335,6 +336,7 @@ private fun RequestDetailCell( title: String, value: String, @DrawableRes iconRes: Int, + iconColor: Color, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -347,7 +349,7 @@ private fun RequestDetailCell( Icon( painter = painterResource(iconRes), contentDescription = null, - tint = Colors.Purple, + tint = iconColor, modifier = Modifier.size(16.dp) ) BodySSB(text = value, maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index e1216b18d0..ab0b13b356 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -54,6 +54,8 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.UiDateStyle +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.PaykitPaymentProofKind @@ -62,6 +64,7 @@ import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitUsdtReceipt import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -70,11 +73,12 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.PaykitAmountCell import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.usdtExactAmount import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.SheetTopBar @@ -199,6 +203,7 @@ fun PaymentRequestsScreen( topPadding: Dp = 0.dp, ) { val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val receipts by appViewModel.paykitUsdtPayments.receipts.collectAsStateWithLifecycle() val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() @@ -208,6 +213,7 @@ fun PaymentRequestsScreen( PaymentRequestsContent( requests = (pending + history).distinctBy { it.id }.toImmutableList(), pending = pending.toImmutableList(), + receipts = receipts.toImmutableList(), contacts = contacts.toImmutableList(), subscriptions = subscriptions.toImmutableList(), dismissingRequestIds = dismissingRequestIds.toImmutableSet(), @@ -224,6 +230,7 @@ fun PaymentRequestsScreen( @Composable internal fun PaymentRequestsContent( + receipts: ImmutableList = persistentListOf(), modifier: Modifier = Modifier, requests: ImmutableList, pending: ImmutableList, @@ -338,6 +345,7 @@ internal fun PaymentRequestsContent( paymentRequestStatus(request) }, showSignedAmount = request.hasPaymentEvidence, + receipt = receipts.lastOrNull { it.matches(request) }, onClick = { onDetails(request.id) }, ) } @@ -477,7 +485,23 @@ internal fun paymentRequestDate(request: PaykitPaymentRequest): String = request } ?: paymentRequestStatus(request) @Composable -internal fun paymentRequestStatus(request: PaykitPaymentRequest, isPending: Boolean = false): String { +internal fun paymentRequestStatus( + request: PaykitPaymentRequest, + isPending: Boolean = false, + receipt: PaykitUsdtReceipt? = null, +): String { + if (request.direction == PaykitPaymentRequestDirection.Outgoing && + request.paymentProofKind == PaykitPaymentProofKind.Usdt + ) { + return receipt?.takeIf { it.matches(request) }?.let { paykitUsdtReceiptText(it) } + ?: stringResource(R.string.wallet__payment_request_pending) + } + + return paymentRequestLifecycleStatus(request, isPending) +} + +@Composable +private fun paymentRequestLifecycleStatus(request: PaykitPaymentRequest, isPending: Boolean): String { if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { return stringResource(R.string.wallet__payment_request_status_expired) } @@ -487,7 +511,7 @@ internal fun paymentRequestStatus(request: PaykitPaymentRequest, isPending: Bool PaymentRequestLifecycleState.PROPOSAL_EXPIRED -> stringResource(R.string.wallet__payment_request_status_expired) PaymentRequestLifecycleState.ACCEPTED -> - stringResource(R.string.wallet__payment_request_status_accepted) + stringResource(R.string.wallet__payment_request_pending) PaymentRequestLifecycleState.REJECTED -> stringResource(R.string.wallet__payment_request_status_rejected) PaymentRequestLifecycleState.CANCELED -> @@ -514,6 +538,20 @@ internal fun proposedPaymentRequestStatusRes(request: PaykitPaymentRequest, isPe else -> R.string.wallet__payment_request_sending } +@Composable +private fun paymentRequestAmountStatus( + request: PaykitPaymentRequest, + receipt: PaykitUsdtReceipt?, + fiatStatus: String?, +): String? = receipt?.let { paykitUsdtReceiptText(it) } ?: fiatStatus + ?: if (request.direction == PaykitPaymentRequestDirection.Outgoing && + request.paymentProofKind == PaykitPaymentProofKind.Usdt + ) { + stringResource(R.string.wallet__payment_request_pending) + } else { + null + } + @Composable internal fun PaymentRequestCard( request: PaykitPaymentRequest, @@ -524,6 +562,7 @@ internal fun PaymentRequestCard( showSignedAmount: Boolean = false, showBitcoinSymbol: Boolean = true, fiatStatus: String? = null, + receipt: PaykitUsdtReceipt? = null, onClick: (() -> Unit)? = null, isDismissing: Boolean = false, onPay: (() -> Unit)? = null, @@ -581,11 +620,11 @@ internal fun PaymentRequestCard( overflow = TextOverflow.Ellipsis, ) } - MoneyCell( - sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + PaykitAmountCell( + amount = request.amount, prefix = amountPrefix, showBitcoinSymbol = showBitcoinSymbol, - fiatReplacement = fiatStatus, + status = paymentRequestAmountStatus(request, receipt, fiatStatus), ) } if (onPay != null || onDismiss != null) { @@ -668,10 +707,18 @@ private val PaykitPaymentRequest.lazyListKey: String get() = "$paymentRequestId|$counterparty|${billingPeriod?.startsAt ?: ""}" internal val PaykitPaymentRequest.paymentRailIconColor - get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple else Colors.Brand + get() = when (paymentProofKind) { + PaykitPaymentProofKind.Usdt -> Colors.Usdt + PaykitPaymentProofKind.Lightning -> Colors.Purple + else -> Colors.Brand + } internal val PaykitPaymentRequest.paymentRailBackgroundColor - get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple16 else Colors.Brand16 + get() = when (paymentProofKind) { + PaykitPaymentProofKind.Usdt -> Colors.Usdt16 + PaykitPaymentProofKind.Lightning -> Colors.Purple16 + else -> Colors.Brand16 + } private fun PaykitPaymentRequest.showsPaymentRailIcon(isOutgoingPayment: Boolean): Boolean = isOutgoingPayment || hasPaymentEvidence @@ -692,8 +739,8 @@ private fun PaymentRailIcon(request: PaykitPaymentRequest, paymentWasSent: Boole private val previewRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - amountValue = "0.00025", - amountSats = 25_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 25_000uL), + paymentReference = "preview", note = "Dinner", createdAt = Instant.parse("2027-01-15T08:00:00Z"), expiresAt = Instant.parse("2027-01-15T09:00:00Z"), @@ -745,3 +792,14 @@ private fun PaymentRequestsPreview() { ) } } + +@Composable +internal fun paykitUsdtReceiptText(receipt: PaykitUsdtReceipt): String { + val label = when { + !receipt.verified -> R.string.wallet__payment_request_pending + receipt.underpaid -> R.string.wallet__payment_request_underpaid + receipt.afterExpiry -> R.string.wallet__payment_request_after_expiry + else -> R.string.wallet__payment_request_payment_verified + } + return stringResource(label) + " · " + usdtExactAmount(receipt.amount.atomic) + " USDT" +} diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt index 58afffee44..a60fd76819 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.profile import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -15,10 +16,12 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -33,9 +36,12 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -65,7 +71,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported @@ -80,7 +86,6 @@ fun PubkyAuthApprovalSheet( onDismiss: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - DisposableEffect(viewModel, authUrl) { onDispose { viewModel.cancelLocalAuth(authUrl) } } @@ -100,6 +105,8 @@ fun PubkyAuthApprovalSheet( onBackToWatchOnly = { if (uiState.authUrl == authUrl) viewModel.returnToWatchOnlyConsent(authUrl) }, + onRetryUsdt = { viewModel.loadUsdtAddress(authUrl) }, + onShareUsdt = viewModel::setShareUsdt, onCancel = { viewModel.dismiss() }, onDismiss = { viewModel.dismiss() }, ) @@ -214,12 +221,14 @@ private fun Content( onBackToWatchOnly: () -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, + onRetryUsdt: () -> Unit = {}, + onShareUsdt: (Boolean) -> Unit = {}, ) { val approvalState = if (isCurrentRequest) uiState.state else ApprovalState.Loading val headerTitle = approvalHeaderTitle(approvalState) val onBack = approvalBackAction( approvalState = approvalState, - bitkitClaim = uiState.bitkitClaim, + showsReceivingDetails = uiState.showsReceivingDetails, onBackToWatchOnly = onBackToWatchOnly, onCancel = onCancel, onDismiss = onDismiss, @@ -228,7 +237,7 @@ private fun Content( Column( modifier = Modifier .sheetHeight(SheetSize.LARGE) - .gradientBackground() + .background(Colors.Gray7) .navigationBarsPadding() .padding(horizontal = 16.dp) ) { @@ -237,6 +246,9 @@ private fun Content( when (approvalState) { ApprovalState.Loading -> LoadingContent() ApprovalState.WatchOnlyConsent -> WatchOnlyConsentContent( + uiState = uiState, + onRetryUsdt = onRetryUsdt, + onShareUsdt = onShareUsdt, onApprove = onApproveWatchOnly, onCancel = onCancel, ) @@ -265,12 +277,12 @@ private fun approvalHeaderTitle(approvalState: ApprovalState): String = when (ap private fun approvalBackAction( approvalState: ApprovalState, - bitkitClaim: PubkyAuthClaim?, + showsReceivingDetails: Boolean, onBackToWatchOnly: () -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, ): (() -> Unit)? = when (approvalState) { - ApprovalState.Authorize if bitkitClaim?.includesWatchOnlyAccount == true -> onBackToWatchOnly + ApprovalState.Authorize if showsReceivingDetails -> onBackToWatchOnly ApprovalState.Authorize, ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel ApprovalState.Success -> onDismiss else -> null @@ -278,60 +290,123 @@ private fun approvalBackAction( @Composable private fun ColumnScope.WatchOnlyConsentContent( + uiState: PubkyAuthApprovalUiState, onApprove: () -> Unit, onCancel: () -> Unit, + onRetryUsdt: () -> Unit, + onShareUsdt: (Boolean) -> Unit, ) { - Column( - modifier = Modifier - .weight(1f) - .testTag("PubkyAuthWatchOnlyConsent") - ) { - FillHeight(min = 32.dp) - - Image( - painter = painterResource(R.drawable.coin_stack_4), - contentDescription = null, - modifier = Modifier - .size(256.dp) - .align(Alignment.CenterHorizontally) - .graphicsLayer { - scaleX = COIN_SCALE - scaleY = COIN_SCALE - translationY = COIN_OFFSET_Y.toPx() - }, - ) - + Column(modifier = Modifier.weight(1f).testTag("PubkyAuthWatchOnlyConsent")) { + Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f).fillMaxWidth()) { + Image( + painter = painterResource(R.drawable.coin_stack_4), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .graphicsLayer { + scaleX = COIN_SCALE + scaleY = COIN_SCALE + translationY = COIN_OFFSET_Y.toPx() + }, + ) + } + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.profile__auth_approval_watch_only_intro_title) + .withAccent(accentColor = Colors.Blue), + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource( + if (uiState.bitkitClaim?.sharesBitcoin == true) { + R.string.profile__auth_approval_watch_only_intro_description + } else { + R.string.profile__auth_approval_usdt_intro_description + } + ), + color = Colors.White64, + ) + } + } + if (uiState.showsUsdtDetails) { VerticalSpacer(32.dp) - - Display( - text = stringResource(R.string.profile__auth_approval_watch_only_intro_title) - .withAccent(accentColor = Colors.Blue), + UsdtSharingChoice(uiState, onShareUsdt, onRetryUsdt) + } + VerticalSpacer(32.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + modifier = Modifier.weight(1f).testTag("PubkyAuthWatchOnlyCancel") ) - VerticalSpacer(8.dp) - BodyM( - text = stringResource(R.string.profile__auth_approval_watch_only_intro_description), - color = Colors.White64, + PrimaryButton( + text = stringResource(R.string.profile__auth_approval_watch_only_intro_approve), + onClick = onApprove, + enabled = uiState.canAuthorize, + modifier = Modifier.weight(1f).testTag("PubkyAuthWatchOnlyApprove") ) + } + VerticalSpacer(16.dp) +} - VerticalSpacer(32.dp) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - SecondaryButton( - text = stringResource(R.string.common__cancel), - onClick = onCancel, - modifier = Modifier - .weight(1f) - .testTag("PubkyAuthWatchOnlyCancel"), - ) - PrimaryButton( - text = stringResource(R.string.profile__auth_approval_watch_only_intro_approve), - onClick = onApprove, +@Composable +private fun UsdtSharingChoice( + uiState: PubkyAuthApprovalUiState, + onShareUsdt: (Boolean) -> Unit, + onRetryUsdt: () -> Unit, +) { + Column(modifier = Modifier.testTag("PubkyAuthUsdtDetails")) { + if (!uiState.usdtRequired) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier - .weight(1f) - .testTag("PubkyAuthWatchOnlyApprove"), + .toggleable(value = uiState.shareUsdt, role = Role.Switch, onValueChange = onShareUsdt) + .testTag("PubkyAuthShareUsdt") + ) { + BodyM( + text = stringResource(R.string.profile__auth_approval_share_usdt_optional), + color = Colors.White64, + modifier = Modifier.weight(1f) + ) + Switch( + checked = uiState.shareUsdt, + onCheckedChange = null, + colors = AppSwitchDefaults.colors.copy( + checkedTrackColor = Colors.Blue, + checkedBorderColor = Colors.Blue, + checkedIconColor = Colors.Blue, + ), + modifier = Modifier.clearAndSetSemantics {} + ) + } + } + if (uiState.shareUsdt || uiState.usdtRequired) { + if (!uiState.usdtRequired) VerticalSpacer(8.dp) + BodyS(text = stringResource(R.string.profile__auth_approval_usdt_privacy), color = Colors.White64) + } + if (uiState.usdtAddress == null) { + VerticalSpacer(8.dp) + BodyS( + text = stringResource( + when { + uiState.usdtUnavailable && uiState.usdtRequired -> R.string.usdt__error_configuration + uiState.usdtUnavailable -> R.string.profile__auth_approval_usdt_unavailable + else -> R.string.profile__auth_approval_usdt_loading + } + ), + color = Colors.White64, ) + if (uiState.usdtUnavailable) { + VerticalSpacer(8.dp) + BodySSB( + text = stringResource(R.string.common__retry), + color = Colors.Blue, + modifier = Modifier.clickable(onClick = onRetryUsdt).testTag("PubkyAuthRetryUsdt") + ) + } } - VerticalSpacer(16.dp) } } @@ -364,6 +439,7 @@ private fun ColumnScope.AuthorizeContent( PrimaryButton( text = stringResource(R.string.profile__auth_approval_authorize), onClick = onAuthorize, + enabled = uiState.canAuthorize, modifier = Modifier .weight(1f) .testTag("PubkyAuthAuthorize") @@ -413,13 +489,18 @@ private fun ColumnScope.ApprovalDetails( if (uiState.permissions.isNotEmpty()) { DescriptionText(clientId = uiState.clientId, serviceName = uiState.serviceName) VerticalSpacer(32.dp) + } + if (uiState.usdtRequired && !uiState.isUsdtEnabled) { + BodyM(text = stringResource(R.string.profile__auth_approval_usdt_disabled), color = Colors.White64) + VerticalSpacer(24.dp) + } + if (uiState.permissions.isNotEmpty()) { PermissionsSection(permissions = uiState.permissions) } if (uiState.bitkitClaim?.includesPaykitAccess == true) { VerticalSpacer(32.dp) PaykitAccessSection() } - VerticalSpacer(32.dp) } Column { @@ -451,7 +532,7 @@ private fun PaykitAccessSection() { title = stringResource(R.string.profile__auth_approval_details), modifier = Modifier.testTag("PubkyAuthPaykitAccess") ) { - BodyS(text = stringResource(R.string.profile__auth_approval_paykit_access_description)) + BodyS(text = stringResource(R.string.profile__auth_approval_paykit_access_description), letterSpacing = 0.sp) } } @@ -468,13 +549,25 @@ private fun ColumnScope.SuccessContent( .verticalScroll(rememberScrollState()) .heightIn(min = maxHeight) ) { + val sharedDetails = uiState.bitkitClaim?.takeIf { + it.sharesBitcoin || (it.sharesUsdt && uiState.shareUsdt) + }?.let { claim -> + stringResource( + when { + !claim.sharesBitcoin -> R.string.profile__auth_approval_shared_usdt + claim.sharesUsdt && uiState.shareUsdt -> R.string.profile__auth_approval_shared_both + else -> R.string.profile__auth_approval_shared_bitcoin + } + ) + } SuccessDescriptionText( clientId = uiState.clientId, serviceName = uiState.serviceName, truncatedKey = uiState.profile?.authDisplayPublicKey.orEmpty(), + sharedDetails = sharedDetails, modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) + .then(if (sharedDetails != null) Modifier.testTag("PubkyAuthSharedDetails") else Modifier) ) - Image( painter = painterResource(R.drawable.check), contentDescription = null, @@ -507,7 +600,7 @@ private fun DescriptionText(clientId: String, serviceName: String) { } else { stringResource(R.string.profile__auth_approval_service, service) } - BodyM(text = text.withAccentBoldBright(), color = Colors.White64) + BodyM(text = text.withAccentBoldBright(), color = Colors.White64, letterSpacing = 0.sp) } @Composable @@ -516,6 +609,7 @@ private fun SuccessDescriptionText( serviceName: String, truncatedKey: String, modifier: Modifier = Modifier, + sharedDetails: String? = null, ) { val requester = clientId.asLiteralRequestText() val service = serviceName.asLiteralRequestText() @@ -524,7 +618,12 @@ private fun SuccessDescriptionText( } else { stringResource(R.string.profile__auth_approval_success_detail, truncatedKey, service) } - BodyM(text = text.withAccentBoldBright(), color = Colors.White64, modifier = modifier) + BodyM( + text = (text + sharedDetails?.let { "\n\n$it" }.orEmpty()).withAccentBoldBright(), + color = Colors.White64, + letterSpacing = 0.sp, + modifier = modifier.fillMaxWidth() + ) } @Composable @@ -579,6 +678,7 @@ private fun TrustWarning() { BodyS( text = stringResource(R.string.profile__auth_approval_trust_warning), color = Colors.White64, + letterSpacing = 0.sp, ) } } @@ -592,6 +692,7 @@ private fun ProfileCard(profile: PubkyProfile) { .fillMaxWidth() .background(Colors.Gray6, RoundedCornerShape(16.dp)) .padding(24.dp) + .testTag("PubkyAuthProfile") ) { if (profile.imageUrl != null) { PubkyImage(uri = profile.imageUrl, size = 48.dp) @@ -620,6 +721,7 @@ private fun ProfileCard(profile: PubkyProfile) { ) BodyMSB( text = profile.name, + letterSpacing = 0.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 47ad67e380..8dd9bc71ad 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R @@ -25,8 +26,11 @@ import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.UsdtPreferencesRepo +import to.bitkit.repositories.UsdtRepo import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -40,6 +44,8 @@ class PubkyAuthApprovalViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, private val watchOnlyAccountRepo: WatchOnlyAccountRepo, + private val usdtRepo: UsdtRepo, + private val usdtPreferences: UsdtPreferencesRepo, ) : ViewModel() { companion object { private const val TAG = "PubkyAuthApprovalVM" @@ -51,6 +57,33 @@ class PubkyAuthApprovalViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + init { + viewModelScope.launch { + usdtPreferences.enabled.collect { enabled -> + val newlyEnabled = enabled && !_uiState.value.isUsdtEnabled + _uiState.update { state -> + state.copy( + isUsdtEnabled = enabled, + shareUsdt = state.shareUsdt && enabled, + usdtAddress = state.usdtAddress.takeIf { enabled }, + state = if (!enabled && state.state == ApprovalState.WatchOnlyConsent && + state.bitkitClaim?.sharesBitcoin != true + ) { + ApprovalState.Authorize + } else if (newlyEnabled && state.usdtRequired && + state.state == ApprovalState.Authorize + ) { + ApprovalState.WatchOnlyConsent + } else { + state.state + }, + ) + } + if (newlyEnabled) loadUsdtAddress(_uiState.value.authUrl) + } + } + } + private val inFlightAuthorization = AtomicReference() fun load(authUrl: String) { inFlightAuthorization.get()?.takeIf { it.authUrl == authUrl }?.let { authorization -> @@ -76,10 +109,15 @@ class PubkyAuthApprovalViewModel @Inject constructor( } if (_uiState.value.authUrl != authUrl) return@launch val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) - val serviceName = request.serviceNames.firstOrNull() ?: unknownService + val serviceName = request.serviceNames.takeIf { + it.isNotEmpty() + }?.joinToString(context.getString(R.string.profile__auth_approval_services_separator)) ?: unknownService + val usdtEnabled = usdtPreferences.enabled.first() _uiState.update { it.copy( - state = if (request.bitkitClaim?.includesWatchOnlyAccount == true) { + state = if (request.bitkitClaim?.sharesBitcoin == true || + (request.bitkitClaim?.sharesUsdt == true && usdtEnabled) + ) { ApprovalState.WatchOnlyConsent } else { ApprovalState.Authorize @@ -91,14 +129,49 @@ class PubkyAuthApprovalViewModel @Inject constructor( permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, profile = approvalProfile(), + isUsdtEnabled = usdtEnabled, + shareUsdt = usdtEnabled, ) } + if (usdtEnabled) loadUsdtAddress(authUrl) + } + } + + fun setShareUsdt(enabled: Boolean) { + if (_uiState.value.state !in listOf(ApprovalState.WatchOnlyConsent, ApprovalState.Authorize) || + !_uiState.value.isUsdtEnabled || _uiState.value.usdtRequired + ) { + return + } + _uiState.update { it.copy(shareUsdt = enabled) } + } + + fun loadUsdtAddress(authUrl: String) { + if (_uiState.value.authUrl != authUrl || _uiState.value.bitkitClaim?.sharesUsdt != true) return + _uiState.update { it.copy(usdtAddress = null, usdtUnavailable = false) } + viewModelScope.launch { + if (!usdtPreferences.enabled.first()) { + _uiState.update { if (it.authUrl == authUrl) it.copy(shareUsdt = false) else it } + return@launch + } + val address = usdtRepo.paymentEndpoint().getOrNull()?.value + _uiState.update { + if (it.authUrl == authUrl && it.isUsdtEnabled) { + it.copy( + usdtAddress = address, + usdtUnavailable = address == null, + shareUsdt = (it.usdtRequired || it.shareUsdt) && address != null, + ) + } else { + it + } + } } } fun requestAuthorize(authUrl: String) { val state = _uiState.value - if (state.authUrl != authUrl || state.state != ApprovalState.Authorize) return + if (state.authUrl != authUrl || state.state != ApprovalState.Authorize || !state.canAuthorize) return if (!_uiState.compareAndSet(state, state.copy(state = ApprovalState.Authenticating))) return viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl)) @@ -107,7 +180,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( fun approveWatchOnlyConsent(authUrl: String) { _uiState.update { state -> - if (state.authUrl == authUrl && state.state == ApprovalState.WatchOnlyConsent) { + if (state.authUrl == authUrl && state.state == ApprovalState.WatchOnlyConsent && state.canAuthorize) { state.copy(state = ApprovalState.Authorize) } else { state @@ -120,7 +193,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( if ( state.authUrl == authUrl && state.state == ApprovalState.Authorize && - state.bitkitClaim?.includesWatchOnlyAccount == true + state.showsReceivingDetails ) { state.copy(state = ApprovalState.WatchOnlyConsent) } else { @@ -203,8 +276,15 @@ class PubkyAuthApprovalViewModel @Inject constructor( request: PubkyAuthRequest, authUrl: String, ): Boolean { + val endpoint = runSuspendCatching { + validatedUsdtEndpoint(request) + }.getOrElse { + handleApprovalFailure(it, authUrl) + loadUsdtAddress(authUrl) + return false + } val preparedClaim = runSuspendCatching { - if (request.bitkitClaim?.includesWatchOnlyAccount == true) { + if (request.bitkitClaim?.sharesBitcoin == true) { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, defaultWatchOnlyAccountName(request)) } else { null @@ -228,10 +308,16 @@ class PubkyAuthApprovalViewModel @Inject constructor( } } - val approvalResult = if (request.bitkitClaim != null) { - pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, preparedClaim?.payload ?: byteArrayOf()) - } else { - pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) + val approvalResult = runSuspendCatching { + request.bitkitClaim?.let { claimType -> + val sharedEndpoint = endpoint.takeIf { usdtPreferences.enabled.first() } + if (claimType.requiresUsdtAddress && sharedEndpoint == null) { + throw PubkyAuthRequestError.InvalidPaymentDetails + } + _uiState.update { it.copy(shareUsdt = sharedEndpoint != null) } + val payload = claimType.unsignedPayload(preparedClaim, sharedEndpoint) + pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, payload).getOrThrow() + } ?: pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId).getOrThrow() } if (approvalResult.isFailure) { val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" } @@ -247,21 +333,27 @@ class PubkyAuthApprovalViewModel @Inject constructor( return false } - preparedClaim?.let { claim -> - runSuspendCatching { watchOnlyAccountRepo.markActive(claim.account.id) }.getOrElse { - handleApprovalFailure(it, authUrl) - return false - } + return runSuspendCatching { + preparedClaim?.let { watchOnlyAccountRepo.markActive(it.account.id) } + }.onFailure { handleApprovalFailure(it, authUrl) }.isSuccess + } + + private suspend fun validatedUsdtEndpoint(request: PubkyAuthRequest): Endpoint? { + val claim = request.bitkitClaim ?: return null + if (!claim.sharesUsdt) return null + if (!_uiState.value.shareUsdt || !usdtPreferences.enabled.first()) { + if (claim.requiresUsdtAddress) throw PubkyAuthRequestError.InvalidPaymentDetails + return null + } + return usdtRepo.paymentEndpoint().getOrThrow().also { + if (it.value != _uiState.value.usdtAddress) throw PubkyAuthRequestError.InvalidPaymentDetails } - return true } private fun transitionToAuthorizing(authUrl: String): PubkyAuthApprovalUiState? { val initialState = _uiState.value - if ( - initialState.authUrl != authUrl || - (initialState.state != ApprovalState.Authorize && initialState.state != ApprovalState.Authenticating) - ) { + val ready = initialState.state in setOf(ApprovalState.Authorize, ApprovalState.Authenticating) + if (initialState.authUrl != authUrl || !initialState.canAuthorize || !ready) { return null } val authorizingState = initialState.copy(state = ApprovalState.Authorizing) @@ -348,7 +440,19 @@ data class PubkyAuthApprovalUiState( val permissions: ImmutableList = persistentListOf(), val bitkitClaim: PubkyAuthClaim? = null, val profile: PubkyProfile? = null, -) + val usdtAddress: String? = null, + val usdtUnavailable: Boolean = false, + val shareUsdt: Boolean = true, + val isUsdtEnabled: Boolean = false, +) { + val showsUsdtDetails: Boolean get() = bitkitClaim?.sharesUsdt == true && isUsdtEnabled + val showsReceivingDetails: Boolean get() = bitkitClaim?.sharesBitcoin == true || showsUsdtDetails + val usdtRequired: Boolean get() = bitkitClaim?.requiresUsdtAddress == true + val canAuthorize: Boolean get() = when { + usdtRequired -> isUsdtEnabled && shareUsdt && usdtAddress != null + else -> !showsUsdtDetails || !shareUsdt || usdtAddress != null + } +} sealed interface ApprovalState { data object Loading : ApprovalState @@ -379,3 +483,6 @@ private fun Throwable.isPostDeliveryAuthorizationFailure(): Boolean { } return false } + +private val PubkyAuthClaim.requiresUsdtAddress: Boolean + get() = sharesUsdt && !sharesBitcoin && !includesPaykitAccess diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionView.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionView.kt index 84f05f180e..9fac8ef1a6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionView.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/CameraPermissionView.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -56,7 +57,9 @@ fun CameraPermissionView( @Composable fun DeniedContent( onClickRetry: () -> Unit = {}, - onClickPaste: () -> Unit = {}, + onClickPaste: (() -> Unit)? = {}, + description: String = stringResource(R.string.other__camera_permission_description), + accentColor: Color = Colors.Brand, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -75,14 +78,14 @@ fun DeniedContent( ) { Display( stringResource(R.string.other__camera_permission_title) - .withAccent(accentColor = Colors.Brand), + .withAccent(accentColor = accentColor), color = Colors.White, ) VerticalSpacer(8.dp) BodyM( - stringResource(R.string.other__camera_permission_description), + description, color = Colors.White64, modifier = Modifier.fillMaxWidth(), ) @@ -103,16 +106,18 @@ fun DeniedContent( FillHeight() - PrimaryButton( - text = stringResource(R.string.other__qr_paste), - icon = { - Icon( - painter = painterResource(R.drawable.ic_clipboard_text_simple), - contentDescription = null, - ) - }, - onClick = onClickPaste, - ) + if (onClickPaste != null) { + PrimaryButton( + text = stringResource(R.string.other__qr_paste), + icon = { + Icon( + painter = painterResource(R.drawable.ic_clipboard_text_simple), + contentDescription = null, + ) + }, + onClick = onClickPaste, + ) + } } } 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 d3732f9aeb..127c86e455 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 @@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -95,6 +96,8 @@ fun QrScanningScreen( isFullScreen: Boolean = false, acceptsBinaryPayload: Boolean = false, showsGradientBackground: Boolean = true, + permissionDescription: String = stringResource(R.string.other__camera_permission_description), + permissionAccentColor: Color = Colors.Brand, ) { val app = appViewModel ?: return @@ -225,6 +228,8 @@ fun QrScanningScreen( permissionState = cameraPermissionState, deniedContent = { DeniedContent( + description = permissionDescription, + accentColor = permissionAccentColor, onClickRetry = { context.startActivityAppSettings() }, onClickPaste = handlePaste(context, app, setScanResult), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index c7b04259c7..0d2bb88539 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -21,6 +23,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -43,12 +46,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -56,21 +62,29 @@ import kotlinx.coroutines.withContext import to.bitkit.R import to.bitkit.ext.getClipboardText import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAmountConversion +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionDraft +import to.bitkit.repositories.paykitRate +import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display -import to.bitkit.ui.components.MoneyDisplay +import to.bitkit.ui.components.PaykitAcceptedAssets +import to.bitkit.ui.components.PaykitAmountDisplay import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactRow import to.bitkit.ui.components.TextInput @@ -92,6 +106,7 @@ import to.bitkit.utils.SubscriptionClockOffset import to.bitkit.utils.SubscriptionIcon import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Clock import kotlin.time.ExperimentalTime @Composable @@ -100,12 +115,15 @@ fun CreateSubscriptionSheet( amountInputViewModel: AmountInputViewModel = hiltViewModel(key = "SubscriptionAmount"), ) { val context = LocalContext.current + val currencies = LocalCurrencies.current val scope = rememberCoroutineScope() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val receivingMethods by appViewModel.paykitReceivingMethods.collectAsStateWithLifecycle() var step by remember { mutableStateOf(SubscriptionCreationStep.Details) } - var amountSats by remember { mutableStateOf(0uL) } + var amount by remember { mutableStateOf(PaykitAmount(PaykitAsset.BTC, 0uL)) } + val amountConversion = remember { PaykitAmountConversion() } var name by remember { mutableStateOf("") } var description by remember { mutableStateOf("") } var frequency by remember { mutableStateOf(PaykitRecurrenceUnit.Month) } @@ -118,13 +136,36 @@ fun CreateSubscriptionSheet( when (step) { SubscriptionCreationStep.Details -> CreateSubscriptionDetails( - amountSats = amountSats, + amount = amount, + availableMethods = receivingMethods.toImmutableList(), + acceptedMethods = PaykitRequestPricing.subscriptionEndpoints( + amount.asset, + receivingMethods, + ).toImmutableList(), name = name, description = description, frequency = frequency, selectedIconUri = selectedIconUri, isLoadingIcon = isLoadingIcon, onAmountClick = { step = SubscriptionCreationStep.Amount }, + onClickAsset = { asset -> + if (asset != amount.asset) { + runCatching { + amountConversion.convert( + amount, + asset, + currencies.paykitRate, + Clock.System.now().toEpochMilliseconds() + ) + }.onSuccess { amount = it }.onFailure { + appViewModel.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.wallet__payment_request_rate_unavailable), + ) + } + } + }, onNameChange = { name = it.take(256) }, onDescriptionChange = { description = it.take(1024) }, onFrequencyChange = { frequency = it }, @@ -152,15 +193,19 @@ fun CreateSubscriptionSheet( ) SubscriptionCreationStep.Amount -> PaymentRequestAmountContent( amountInputViewModel = amountInputViewModel, + allowedAssets = listOf(PaykitAsset.BTC, PaykitAsset.USD).filter { + PaykitRequestPricing.subscriptionEndpoints(it, receivingMethods).isNotEmpty() + }.toImmutableList(), + conversion = amountConversion, initialDraft = PaykitPaymentRequestDraft( - amountSats = amountSats, + amount = amount, note = name, expiresAt = SubscriptionClockOffset.subscriptionNow() + expiration.duration, ), contact = null, onBack = { step = SubscriptionCreationStep.Details }, onContinue = { - amountSats = it.amountSats + amount = it.amount step = SubscriptionCreationStep.Details }, modifier = Modifier.sheetHeight() @@ -180,7 +225,7 @@ fun CreateSubscriptionSheet( selectedTarget?.let { target -> appViewModel.createSubscription( draft = PaykitSubscriptionDraft( - amountSats = amountSats, + amount = amount, name = name, description = description, frequency = frequency, @@ -210,13 +255,16 @@ fun CreateSubscriptionSheet( @Composable internal fun CreateSubscriptionDetails( - amountSats: ULong, + amount: PaykitAmount, + availableMethods: ImmutableList, + acceptedMethods: ImmutableList, name: String, description: String, frequency: PaykitRecurrenceUnit, selectedIconUri: Uri?, isLoadingIcon: Boolean, onAmountClick: () -> Unit, + onClickAsset: (PaykitAsset) -> Unit, onNameChange: (String) -> Unit, onDescriptionChange: (String) -> Unit, onFrequencyChange: (PaykitRecurrenceUnit) -> Unit, @@ -241,7 +289,7 @@ internal fun CreateSubscriptionDetails( Column( modifier = modifier .fillMaxSize() - .gradientBackground() + .background(Colors.Gray7) .navigationBarsPadding() .imePadding() .padding(horizontal = 16.dp) @@ -250,17 +298,17 @@ internal fun CreateSubscriptionDetails( SheetTopBar(titleText = stringResource(R.string.subscriptions__create_subscription)) LazyColumn(modifier = Modifier.weight(1f)) { item { + VerticalSpacer(8.dp) Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().clickableAlpha(onClick = onAmountClick) + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.clickableAlpha(onClick = onAmountClick).testTag("SubscriptionEditAmount") ) { - MoneyDisplay( - sats = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - showSymbol = true, - modifier = Modifier.weight(1f, fill = false) + PaykitAmountDisplay( + amount = amount, + modifier = Modifier.weight(1f, fill = false).width(IntrinsicSize.Max) ) Icon( painter = painterResource(R.drawable.ic_pencil_simple), @@ -274,6 +322,15 @@ internal fun CreateSubscriptionDetails( VerticalSpacer(8.dp) SubscriptionFrequencyPicker(frequency, onFrequencyChange) VerticalSpacer(24.dp) + PaykitAcceptedAssets( + title = stringResource(R.string.subscriptions__accepted_payments), + availableMethods = availableMethods, + selectedMethods = acceptedMethods, + testTagPrefix = "Subscription", + onClickAsset = onClickAsset, + modifier = Modifier.testTag("SubscriptionAcceptedMethods") + ) + VerticalSpacer(24.dp) Caption13Up(text = stringResource(R.string.subscriptions__name), color = Colors.White64) VerticalSpacer(8.dp) TextInput( @@ -284,44 +341,45 @@ internal fun CreateSubscriptionDetails( modifier = Modifier.fillMaxWidth().testTag("SubscriptionName") ) VerticalSpacer(24.dp) - Caption13Up(text = stringResource(R.string.subscriptions__description), color = Colors.White64) - VerticalSpacer(8.dp) - TextInput( - value = description, - onValueChange = onDescriptionChange, - placeholder = stringResource(R.string.subscriptions__description_placeholder), - minLines = 3, - maxLines = 3, - modifier = Modifier.fillMaxWidth().testTag("SubscriptionDescription") - ) - VerticalSpacer(24.dp) - Caption13Up(text = stringResource(R.string.subscriptions__custom_icon), color = Colors.White64) - VerticalSpacer(8.dp) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(Colors.White10) - .clickable(enabled = !isLoadingIcon, onClick = launchPhotoPicker) - .padding(16.dp) - .testTag("SubscriptionIconPicker") - ) { - if (selectedIconUri == null) { - SubscriptionDefaultIcon(size = 40.dp) - } else { - AsyncImage( - model = selectedIconUri, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Caption13Up(text = stringResource(R.string.subscriptions__description), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = description, + onValueChange = onDescriptionChange, + placeholder = stringResource(R.string.subscriptions__description_placeholder), + minLines = 4, + maxLines = 4, + modifier = Modifier.fillMaxWidth().testTag("SubscriptionDescription") ) } - CaptionB( - text = stringResource(R.string.subscriptions__custom_icon_description), - color = Colors.White64, - modifier = Modifier.padding(start = 16.dp).weight(1f) - ) + Column { + Caption13Up(text = stringResource(R.string.subscriptions__custom_icon), color = Colors.White64) + VerticalSpacer(8.dp) + val iconDescription = stringResource(R.string.subscriptions__custom_icon_description) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Colors.White10) + .clickable(enabled = !isLoadingIcon, onClick = launchPhotoPicker) + .semantics { contentDescription = iconDescription } + .testTag("SubscriptionIconPicker") + ) { + if (selectedIconUri == null) { + SubscriptionDefaultIcon(size = 40.dp) + } else { + AsyncImage( + model = selectedIconUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) + ) + } + } + } } VerticalSpacer(24.dp) } @@ -329,7 +387,7 @@ internal fun CreateSubscriptionDetails( PrimaryButton( text = stringResource(R.string.subscriptions__choose_recipient), onClick = onChooseRecipient, - enabled = amountSats > 0uL && name.isNotBlank() && !isLoadingIcon, + enabled = amount.atomic > 0uL && name.isNotBlank() && !isLoadingIcon && acceptedMethods.isNotEmpty(), modifier = Modifier.testTag("SubscriptionChooseRecipient") ) VerticalSpacer(16.dp) @@ -535,19 +593,50 @@ private enum class SubscriptionFrequencyOption(val unit: PaykitRecurrenceUnit) : private val creationFrequencies = SubscriptionFrequencyOption.entries.toImmutableList() +@Composable +internal fun SubscriptionPaymentMethods(title: String, endpoints: ImmutableList) { + Column(modifier = Modifier.testTag("SubscriptionAcceptedMethods")) { + Caption13Up( + text = title, + color = Colors.White64, + ) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (MethodId.UsdtArbitrum.rawValue in endpoints) { + BodyMSB(text = "USDT", color = Colors.Usdt) + } + if (endpoints.any { MethodId.fromRawValue(it)?.isOnchain == true }) { + BodyMSB(text = stringResource(R.string.lightning__savings), color = Colors.Brand) + } + if (MethodId.Bolt11.rawValue in endpoints || MethodId.Lnurl.rawValue in endpoints) { + BodyMSB(text = stringResource(R.string.lightning__spending), color = Colors.Purple) + } + if (endpoints.isEmpty()) { + BodyM( + text = stringResource(R.string.wallet__payment_request_status_unavailable), + color = Colors.White64, + ) + } + } + } +} + @Preview(showSystemUi = true) @Composable private fun CreateSubscriptionDetailsPreview() { AppThemeSurface { BottomSheetPreview { CreateSubscriptionDetails( - amountSats = 100_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 100_000uL), + availableMethods = persistentListOf(MethodId.P2wpkh.rawValue, MethodId.UsdtArbitrum.rawValue), + acceptedMethods = persistentListOf(MethodId.P2wpkh.rawValue, MethodId.Bolt11.rawValue), name = "Rent Support", description = "Monthly support", frequency = PaykitRecurrenceUnit.Month, selectedIconUri = null, isLoadingIcon = false, onAmountClick = {}, + onClickAsset = {}, onNameChange = {}, onDescriptionChange = {}, onFrequencyChange = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt index a0136d8606..415cfba54a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt @@ -24,7 +24,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PaykitSubscription import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB -import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.PaykitAmountCell import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.shared.modifiers.clickableAlpha @@ -63,7 +63,7 @@ internal fun SubscriptionRow( overflow = TextOverflow.Ellipsis, ) } - MoneyCell(sats = subscription.displaySats) + PaykitAmountCell(subscription.amount) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index ee8847c07d..52049e6860 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -65,16 +65,23 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.dateTimeFormatterOf +import to.bitkit.ext.nowMillis import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitExchangeRate import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.canceledPaidThrough +import to.bitkit.repositories.paykitRate import to.bitkit.repositories.runsUntilPaidThrough +import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -83,8 +90,8 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.MoneyMSB +import to.bitkit.ui.components.PaykitAmountDisplay import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Sheet @@ -92,6 +99,7 @@ import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.components.usdtDisplaySats import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.SheetTopBar @@ -216,7 +224,7 @@ internal fun SubscriptionsContent( ) { item { SubscriptionMetrics( - monthlyCostSats = subscriptionMonthlyCostSats(subscriptions, now), + monthlyCosts = subscriptionMonthlyCosts(subscriptions, now).toImmutableList(), activeCount = sections.active.size, createdCount = sections.created.size, ) @@ -391,7 +399,7 @@ private fun SubscriptionEmptyState(modifier: Modifier = Modifier) { } @Composable -private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int, createdCount: Int) { +private fun SubscriptionMetrics(monthlyCosts: ImmutableList, activeCount: Int, createdCount: Int) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -407,7 +415,12 @@ private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int, created tint = Colors.Purple, modifier = Modifier.size(24.dp) ) - MoneyMSB(sats = monthlyCostSats, showSymbol = true) + val totalSats = subscriptionMonthlyCostSats(monthlyCosts, LocalCurrencies.current.paykitRate) + if (totalSats != null) { + MoneyMSB(sats = totalSats, showSymbol = true) + } else { + BodyMSB(text = "—") + } } } VerticalDivider(color = Colors.White16, modifier = Modifier.height(50.dp)) @@ -439,6 +452,7 @@ fun SubscriptionDetailScreen( ) { val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val receipts by appViewModel.paykitUsdtPayments.receipts.collectAsStateWithLifecycle() val paymentHistory by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val subscription = subscriptions.firstOrNull { it.id == id } val now = rememberSubscriptionNow(listOfNotNull(subscription).toImmutableList()) @@ -476,8 +490,7 @@ fun SubscriptionDetailScreen( Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { Caption13Up(text = subscription.cadenceText(), color = Colors.White64) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - MoneyDisplay(sats = subscription.displaySats, showSymbol = true) - FillWidth() + PaykitAmountDisplay(subscription.amount, modifier = Modifier.weight(1f)) SubscriptionAvatar( subscription = subscription, contact = contacts.contactFor(subscription), @@ -499,6 +512,7 @@ fun SubscriptionDetailScreen( payments.forEach { payment -> PaymentRequestCard( request = payment, + receipt = receipts.lastOrNull { it.matches(payment) }, contact = contacts.contactFor(subscription), title = subscription.note?.takeIf(String::isNotBlank) ?: stringResource(R.string.subscriptions__subscription), @@ -769,7 +783,7 @@ fun SubscriptionFirstPaymentProgress( } @Composable -private fun SubscriptionReview( +internal fun SubscriptionReview( subscription: PaykitSubscription, contact: PubkyProfile, now: Instant, @@ -787,16 +801,23 @@ private fun SubscriptionReview( .padding(horizontal = 16.dp) ) { SheetTopBar(titleText = stringResource(R.string.subscriptions__review_and_subscribe)) - rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { - Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + if (subscription.amount.asset == PaykitAsset.BTC) { + rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } } - MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + PaykitAmountDisplay(subscription.amount) + VerticalSpacer(24.dp) + SubscriptionPaymentMethods( + title = stringResource(R.string.subscriptions__payment_methods), + endpoints = subscription.acceptedPaymentEndpointIdentifiers.toImmutableList(), + ) VerticalSpacer(24.dp) SubscriptionProviderCard(subscription, contact, onClick = onDetails, showAsCard = true) if (!subscription.recurrence.unit.isSupported) { VerticalSpacer(16.dp) BodyM(text = stringResource(R.string.subscriptions__unsupported_description), color = Colors.White64) - } else if (subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty()) { + } else if (subscription.acceptedPaymentEndpointIdentifiers.isEmpty()) { VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.subscriptions__unsupported_payment_description), @@ -821,7 +842,11 @@ private fun SubscriptionReview( if (loading || subscription.isProposalActionable(now)) { SwipeToConfirm( text = stringResource(R.string.subscriptions__swipe_to_subscribe), - color = Colors.Brand, + color = if (subscription.acceptedPaymentEndpointIdentifiers == listOf(MethodId.UsdtArbitrum.rawValue)) { + Colors.Usdt + } else { + Colors.Brand + }, loading = loading, confirmed = isPaying, onConfirm = { @@ -975,10 +1000,12 @@ private fun SubscriptionCancel( } ) ) - rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { - Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + if (subscription.amount.asset == PaykitAsset.BTC) { + rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } } - MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + PaykitAmountDisplay(subscription.amount) VerticalSpacer(24.dp) SubscriptionProviderCard( subscription = subscription, @@ -1183,29 +1210,49 @@ private fun Instant.formatShortDate(): String = dateTimeFormatterOf("MMMM d") .format(java.time.Instant.ofEpochMilli(toEpochMilliseconds())) internal val PaykitSubscription.displaySats: Long - get() = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() + get() = amount.atomic.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() internal fun subscriptionMonthlyCostSats( + amounts: List, + rate: PaykitExchangeRate?, + now: Long = nowMillis(), +): Long? { + var total = 0L + for (amount in amounts) { + val sats = when { + amount.atomic == 0UL -> 0L + amount.asset == PaykitAsset.BTC -> amount.atomic.takeIf { it <= Long.MAX_VALUE.toULong() }?.toLong() + else -> usdtDisplaySats(amount.value, rate, now) + } ?: return null + if (sats > Long.MAX_VALUE - total) return null + total += sats + } + return total +} + +internal fun subscriptionMonthlyCosts( subscriptions: List, now: Instant, -): Long { - val running = subscriptions.filter { it.isPayer && it.runsUntilPaidThrough(now) } - val total = running.fold(BigDecimal.ZERO) { total, subscription -> - val annualPeriods = when (subscription.recurrence.unit) { - PaykitRecurrenceUnit.Minute -> 525_600L - PaykitRecurrenceUnit.Hour -> 8_760L - PaykitRecurrenceUnit.Day -> 365L - PaykitRecurrenceUnit.Week -> 52L - PaykitRecurrenceUnit.Month -> 12L - PaykitRecurrenceUnit.Year -> 1L +): List { + val active = subscriptions.filter { it.isPayer && it.runsUntilPaidThrough(now) } + val assets = active.map { it.amount.asset }.distinct().ifEmpty { listOf(PaykitAsset.BTC) } + return assets.sortedBy { it.code }.map { asset -> + val total = active.filter { it.amount.asset == asset }.fold(BigDecimal.ZERO) { total, subscription -> + val annualPeriods = when (subscription.recurrence.unit) { + PaykitRecurrenceUnit.Minute -> 525_600L + PaykitRecurrenceUnit.Hour -> 8_760L + PaykitRecurrenceUnit.Day -> 365L + PaykitRecurrenceUnit.Week -> 52L + PaykitRecurrenceUnit.Month -> 12L + PaykitRecurrenceUnit.Year -> 1L + } + val denominator = subscription.recurrence.every.toLong() * 12 + val monthlyCost = BigDecimal(subscription.amount.atomic.toString()) + .multiply(BigDecimal.valueOf(annualPeriods)) + .divide(BigDecimal.valueOf(denominator), MathContext.DECIMAL128) + .setScale(0, RoundingMode.HALF_UP) + total.add(monthlyCost) } - val denominator = subscription.recurrence.every.toLong() * 12 - val monthlyCost = BigDecimal(subscription.amountSats.toString()) - .multiply(BigDecimal.valueOf(annualPeriods)) - .divide(BigDecimal.valueOf(denominator), MathContext.DECIMAL128) - .setScale(0, RoundingMode.HALF_UP) - total.add(monthlyCost) + PaykitAmount(asset, total.coerceAtMost(BigDecimal(ULong.MAX_VALUE.toString())).toPlainString().toULong()) } - return total.coerceAtMost(BigDecimal.valueOf(Long.MAX_VALUE)) - .toLong() } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index 86480a55c0..dce266d87d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -78,12 +78,15 @@ internal fun PublicKeySection( AnimatedVisibility(visible = trezorState.lastPublicKey != null) { trezorState.lastPublicKey?.let { response -> - val onCopyXpub = copyToClipboard(text = response.xpub, label = "xpub") + val onCopyAccountKey = copyToClipboard( + text = response.displayablePublicKey, + label = "Account public key", + ) val onCopyPublicKey = copyToClipboard(text = response.publicKey, label = "Public Key") Column { VerticalSpacer(16.dp) Caption( - text = "xpub:", + text = "Account public key:", color = Colors.White50, ) VerticalSpacer(4.dp) @@ -96,18 +99,18 @@ internal fun PublicKeySection( verticalAlignment = Alignment.CenterVertically ) { Caption( - text = response.xpub, + text = response.displayablePublicKey, color = Colors.Brand, modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), - contentDescription = "Copy xpub", + contentDescription = "Copy account public key", tint = Colors.Brand, modifier = Modifier .size(20.dp) - .clickableAlpha(onClick = onCopyXpub) + .clickableAlpha(onClick = onCopyAccountKey) ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index c1d8a5c16b..eb50ffcb13 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -109,6 +109,9 @@ internal object TrezorPreviewData { private const val SAMPLE_XPUB = "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqY" + "h2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz" + private const val SAMPLE_ZPUB = + "zpub6r8o2p9croV2A7Gh2VZn42iEEv7QCxJrXyqWDUcV7aMapn2nY464gJKzfFT" + + "s2Ry4UnCFT1pmvSru6u1KX4GyRs2ti4SYydbtH17Tg8wL57f" private const val SAMPLE_PUBKEY = "02a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" private const val SAMPLE_CHAIN_CODE = @@ -121,6 +124,9 @@ internal object TrezorPreviewData { val samplePublicKeyResponse = TrezorPublicKeyResponse( xpub = SAMPLE_XPUB, + xpubSegwit = SAMPLE_ZPUB, + descriptor = null, + displayablePublicKey = SAMPLE_ZPUB, path = "m/84'/0'/0'", publicKey = SAMPLE_PUBKEY, chainCode = SAMPLE_CHAIN_CODE, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index e79abfdef6..0ff70e1aba 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -33,6 +33,8 @@ import to.bitkit.env.Env import to.bitkit.models.HwWalletId import to.bitkit.models.KnownDevice import to.bitkit.models.Toast +import to.bitkit.models.addressTypeForDerivationPath +import to.bitkit.models.toAccountType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType import to.bitkit.repositories.TrezorRepo @@ -684,9 +686,21 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(watcher = it.watcher.copy(selectedAccountType = type)) } } + /** + * Copies the last fetched xpub into the watcher and selects the account type of its derivation + * path, since a normalized xpub/tpub would otherwise be detected as legacy. + */ fun populateWatcherFromXpub() { - val xpub = trezorRepo.state.value.lastPublicKey?.xpub ?: return - _uiState.update { it.copy(watcher = it.watcher.copy(extendedKey = xpub)) } + val publicKey = trezorRepo.state.value.lastPublicKey ?: return + val accountType = addressTypeForDerivationPath(publicKey.path)?.toAccountType() + _uiState.update { + it.copy( + watcher = it.watcher.copy( + extendedKey = publicKey.xpub, + selectedAccountType = accountType ?: it.watcher.selectedAccountType, + ) + ) + } } fun startWatcher() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index cfdd51f96e..d64107153e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -98,6 +98,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.NavHostController import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.usdtFormatAmount import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -116,12 +118,14 @@ import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair +import to.bitkit.data.isUsdtEnabled import to.bitkit.env.Env import to.bitkit.models.ActivityBannerType import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType +import to.bitkit.models.PaykitExchangeRate import to.bitkit.models.Suggestion import to.bitkit.models.TransportType import to.bitkit.models.WidgetSize @@ -131,8 +135,12 @@ import to.bitkit.models.effectiveSize import to.bitkit.models.toBalance import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.repositories.paykitRate import to.bitkit.ui.LocalBalances +import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.Routes +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.ActivityBanner import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView @@ -155,6 +163,7 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView +import to.bitkit.ui.components.usdtDisplaySats import to.bitkit.ui.navToActivityDetail import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToAllActivity @@ -164,6 +173,9 @@ import to.bitkit.ui.navigateToTransferIntro import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.UsdtRefreshEffect +import to.bitkit.ui.screens.wallets.usdt.UsdtViewModel +import to.bitkit.ui.screens.wallets.usdt.UsdtWalletBalance import to.bitkit.ui.screens.widgets.blocks.BlockCard import to.bitkit.ui.screens.widgets.blocks.BlockCardSmall import to.bitkit.ui.screens.widgets.blocks.WeatherModel @@ -199,6 +211,7 @@ import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.WalletViewModel +import java.math.BigInteger private const val SMALL_SCREEN_HEIGHT_DP = 800 private const val SMALL_SCREEN_SLOT_CAPACITY = 3 @@ -394,6 +407,7 @@ fun HomeScreen( onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, + onClickUsdt = { walletNavController.navigateTo(Routes.UsdtWallet) }, onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -435,6 +449,7 @@ private fun Content( onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, + onClickUsdt: () -> Unit = {}, onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, hazeState: HazeState = rememberHazeState(), balances: BalanceState = LocalBalances.current, @@ -491,6 +506,7 @@ private fun Content( } } + UsdtHomeRefreshEffect() val density = LocalDensity.current val screenHeightDp = with(density) { LocalWindowInfo.current.containerSize.height.toDp().value.toInt() } val isSmallScreen = screenHeightDp < SMALL_SCREEN_HEIGHT_DP @@ -571,6 +587,7 @@ private fun Content( onNavigateToSavings = onNavigateToSavings, onNavigateToSpending = onNavigateToSpending, onClickHardwareWallet = onClickHardwareWallet, + onClickUsdt = onClickUsdt, ) 1 -> WidgetsPage( @@ -596,6 +613,19 @@ private fun countNonItemSlots(homeUiState: HomeUiState): Int { return bannerSlot + widgetsHintSlot } +@Composable +private fun UsdtHomeRefreshEffect() { + if (LocalInspectionMode.current || !Env.isUsdtEnabled) return + val viewModel = hiltViewModel() + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + if (!viewModel.shouldRefresh(settings.isUsdtEnabled)) return + UsdtRefreshEffect(viewModel, pending = wallet.transfers.any { it.status == UsdtTransferStatus.PENDING }) +} + +private fun usdtHeadlineSats(balance: ULong?, visible: Boolean, rate: PaykitExchangeRate?): Long? = + if (visible && balance != 0uL) balance?.let { usdtDisplaySats(usdtFormatAmount(it), rate) } else 0L + @Suppress("MagicNumber") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -613,10 +643,20 @@ private fun WalletPage( onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, + onClickUsdt: () -> Unit, ) { + val usdt by appViewModel?.usdtWallet?.collectAsStateWithLifecycle() + ?: remember { mutableStateOf(UsdtWalletState()) } + val slotCapacity = if (isSmallScreen) SMALL_SCREEN_SLOT_CAPACITY else LARGE_SCREEN_SLOT_CAPACITY + val showUsdt = appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true + val rate = LocalCurrencies.current.paykitRate + val usdtSats = usdtHeadlineSats(usdt.balance, showUsdt, rate) + val headlineSats = BigInteger( + balances.totalWithHardwareSats.toString() + ).add(BigInteger.valueOf(usdtSats ?: 0L)).min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() val heightStatusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val pullToRefreshState = rememberPullToRefreshState() - val hasActivity = !latestActivities.isNullOrEmpty() + val hasActivity = !latestActivities.isNullOrEmpty() || usdt.transfers.isNotEmpty() PullToRefreshBox( state = pullToRefreshState, @@ -645,7 +685,8 @@ private fun WalletPage( VerticalSpacer(16.dp) BalanceHeaderView( - sats = balances.totalWithHardwareSats.toLong(), + sats = headlineSats, + isUnavailable = usdtSats == null, showEyeIcon = true, testTag = "TotalBalance", modifier = Modifier @@ -659,6 +700,7 @@ private fun WalletPage( onNavigateToSavings = onNavigateToSavings, onNavigateToSpending = onNavigateToSpending, onClickHardwareWallet = onClickHardwareWallet, + onClickUsdt = onClickUsdt, ) VerticalSpacer(32.dp) @@ -690,6 +732,8 @@ private fun WalletPage( ActivityListSimple( items = latestActivities, + maxItems = (slotCapacity - countNonItemSlots(homeUiState)).coerceAtLeast(0), + usdtItems = usdt.transfers, onAllActivityClick = onNavigateToAllActivity, onActivityItemClick = onNavigateToActivityItem, hardwareIds = hardwareIds, @@ -724,9 +768,11 @@ private fun BalancesSection( onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, + onClickUsdt: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth()) { Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) @@ -741,7 +787,6 @@ private fun BalancesSection( .testTag("ActivitySavings") ) VerticalDivider(color = Colors.Gray4) - HorizontalSpacer(16.dp) WalletBalanceView( title = stringResource(R.string.wallet__spending__title), sats = balances.totalLightningSats.toLong(), @@ -753,34 +798,40 @@ private fun BalancesSection( ) } - HwDevices(wallets = hardwareWallets, onClick = onClickHardwareWallet) + AdditionalWallets( + wallets = hardwareWallets, + showUsdt = !LocalInspectionMode.current && + (appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true), + onClickHardwareWallet = onClickHardwareWallet, + onClickUsdt = onClickUsdt, + ) } } -/** - * Hardware wallets flow into a 2-column grid below the Savings/Spending tiles: - * a second device fills the bottom-right column, additional devices wrap onto new rows. - */ @Composable -private fun HwDevices( +private fun AdditionalWallets( wallets: ImmutableList, - onClick: (String) -> Unit, + showUsdt: Boolean, + onClickHardwareWallet: (String) -> Unit, + onClickUsdt: () -> Unit, ) { - wallets.chunked(2).forEach { rowWallets -> - VerticalSpacer(16.dp) + val count = wallets.size + if (showUsdt) 1 else 0 + for (rowStart in 0 until count step 2) { + VerticalSpacer(if (wallets.isEmpty()) 24.dp else 16.dp) Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) ) { - HwDeviceCell(wallet = rowWallets[0], onClick = onClick) - VerticalDivider(color = Colors.Gray4) - HorizontalSpacer(16.dp) - val second = rowWallets.getOrNull(1) - if (second != null) { - HwDeviceCell(wallet = second, onClick = onClick) - } else { - FillWidth() + for (index in rowStart..rowStart + 1) { + if (index > rowStart) VerticalDivider(color = Colors.Gray4) + val wallet = wallets.getOrNull(index) + when { + wallet != null -> HwDeviceCell(wallet = wallet, onClick = onClickHardwareWallet) + showUsdt && index == wallets.size -> UsdtWalletBalance(onClick = onClickUsdt) + else -> FillWidth() + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 972591e4e0..64a0bb17f6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsStore +import to.bitkit.data.isUsdtEnabled +import to.bitkit.env.Env import to.bitkit.models.ActivityBannerType import to.bitkit.models.BannerItem import to.bitkit.models.Suggestion @@ -32,8 +34,10 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.SuggestionsRepo import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.UsdtRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo +import to.bitkit.repositories.hasFundsOrPendingTransfers import to.bitkit.ui.screens.widgets.blocks.toWeatherModel import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -51,6 +55,7 @@ class HomeViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, private val suggestionsRepo: SuggestionsRepo, + private val usdtRepo: UsdtRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -122,9 +127,21 @@ class HomeViewModel @Inject constructor( } } + observeEmptyState() + viewModelScope.launch { createBannersFlow() } + } + + private fun observeEmptyState() { @OptIn(ExperimentalCoroutinesApi::class) val hasActivityFlow = activityRepo.activitiesChanged.mapLatest { activityRepo.getActivities(walletId = null, limit = 1u).getOrNull()?.isNotEmpty() == true + }.combine( + combine(settingsStore.data, usdtRepo.state) { settings, usdt -> + Env.isUsdtEnabled && (settings.isUsdtEnabled || usdt.hasFundsOrPendingTransfers) && + ((usdt.balance ?: 0uL) > 0uL || usdt.transfers.isNotEmpty()) + } + ) { hasActivity, hasUsdtActivity -> + hasActivity || hasUsdtActivity } viewModelScope.launch { @@ -151,7 +168,6 @@ class HomeViewModel @Inject constructor( } }.collect() } - viewModelScope.launch { createBannersFlow() } } private fun setupArticleRotation() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index b8828d7318..9c06a55ff2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -15,6 +16,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.UsdtTransfer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -35,6 +37,26 @@ import to.bitkit.ui.shared.util.screen import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.ActivityListViewModel +internal fun UsdtTransfer.matchesActivityFilter( + tab: ActivityTab, + noTagFilter: Boolean, + search: String, + start: Long?, + end: Long?, +): Boolean { + val tabMatches = when (tab) { + ActivityTab.ALL -> true + ActivityTab.SENT -> !isIncoming + ActivityTab.RECEIVED -> isIncoming + ActivityTab.OTHER -> false + } + val searchMatches = search.isEmpty() || recipient.contains(search, ignoreCase = true) || + txHash?.contains(search, ignoreCase = true) == true || search.equals("USDT", ignoreCase = true) + return tabMatches && noTagFilter && searchMatches && + (start?.let { timestamp >= (it / 1000).toULong() } != false) && + (end?.let { timestamp <= (it / 1000).toULong() } != false) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AllActivityScreen( @@ -51,11 +73,19 @@ fun AllActivityScreen( val startDate by viewModel.startDate.collectAsStateWithLifecycle() val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() + val endDate by viewModel.endDate.collectAsStateWithLifecycle() + val usdt by app.usdtWallet.collectAsStateWithLifecycle() + val usdtItems = remember(usdt.transfers, selectedTab, searchText, selectedTags, startDate, endDate) { + usdt.transfers.filter { + it.matchesActivityFilter(selectedTab, selectedTags.isEmpty(), searchText, startDate, endDate) + }.toImmutableList() + } val tabs = activityTabs val currentTabIndex = tabs.indexOf(selectedTab) AllActivityScreenContent( filteredActivities = filteredActivities, + usdtItems = usdtItems, hardwareIds = hardwareIds, searchText = searchText, onSearchTextChange = { viewModel.setSearchText(it) }, @@ -79,6 +109,7 @@ private fun AllActivityScreenContent( filteredActivities: ImmutableList?, searchText: String, hardwareIds: ImmutableSet = persistentSetOf(), + usdtItems: ImmutableList = persistentListOf(), onSearchTextChange: (String) -> Unit, hasTagFilter: Boolean, selectedTags: ImmutableSet, @@ -130,6 +161,7 @@ private fun AllActivityScreenContent( ) { topPadding -> ActivityListGrouped( items = filteredActivities, + usdtItems = usdtItems, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = onEmptyActivityRowClick, hardwareIds = hardwareIds, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 43ec8a2e7d..161b83063a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.UsdtTransfer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -30,11 +31,14 @@ import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.UsdtActivityRow +import to.bitkit.ui.settingsViewModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import java.time.Instant @@ -58,17 +62,18 @@ fun ActivityListGrouped( showContactAvatar: Boolean = true, hardwareIds: ImmutableSet = persistentSetOf(), titleProvider: @Composable (Activity) -> String? = { null }, + usdtItems: ImmutableList = persistentListOf(), ) { - val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { - mutableStateOf(persistentListOf()) - } + val app = appViewModel + val settings = settingsViewModel + val hideBalance by settings?.hideBalance?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxSize() ) { - if (!items.isNullOrEmpty()) { - val groupedItems = groupActivityItems(items) + if (!items.isNullOrEmpty() || usdtItems.isNotEmpty()) { + val groupedItems = remember(items, usdtItems) { groupActivityItems(items.orEmpty(), usdtItems) } LazyColumn( state = listState, @@ -79,18 +84,15 @@ fun ActivityListGrouped( itemsIndexed( items = groupedItems, key = { index, item -> - when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.scopedId()}" - is Activity.Onchain -> "onchain_${item.scopedId()}" - } - - else -> "item_$index" - } + groupedActivityKey(index, item) } ) { index, item -> when (item) { + is UsdtTransfer -> { + UsdtActivityRow(item, hideBalance, onClick = { app?.navigateToUsdtActivity(item.id) }) + if (index < groupedItems.lastIndex) VerticalSpacer(16.dp) + } + is String -> { Caption13Up( text = item, @@ -115,13 +117,13 @@ fun ActivityListGrouped( placementSpec = tween(durationMillis = 300) ) ) { - ActivityRow( - item = item, - onClick = onActivityItemClick, - testTag = "$activityTestTagPrefix-$index", - title = titleProvider(item) ?: contactActivityTitle(item, contacts), - isHardware = item.scopedId() in hardwareIds, - contact = if (showContactAvatar) contactForActivity(item, contacts) else null, + ActivityContactRow( + item, + onActivityItemClick, + "$activityTestTagPrefix-$index", + titleProvider(item), + hardwareIds, + showContactAvatar ) if (index < groupedItems.lastIndex) { VerticalSpacer(16.dp) @@ -162,6 +164,36 @@ fun ActivityListGrouped( } } +@Composable +private fun ActivityContactRow( + item: Activity, + onClick: (Activity) -> Unit, + testTag: String, + title: String?, + hardwareIds: ImmutableSet, + showContactAvatar: Boolean, +) { + val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(persistentListOf()) + } + ActivityRow( + item = item, + onClick = onClick, + testTag = testTag, + title = title ?: contactActivityTitle(item, contacts), + isHardware = item.scopedId() in hardwareIds, + contact = if (showContactAvatar) contactForActivity(item, contacts) else null + ) +} + +private fun groupedActivityKey(index: Int, item: Any): String = when (item) { + is UsdtTransfer -> "usdt_${item.id}" + is String -> "header_$item" + is Activity.Lightning -> "lightning_${item.scopedId()}" + is Activity.Onchain -> "onchain_${item.scopedId()}" + else -> "item_$index" +} + @Suppress("LongMethod", "LongParameterList") fun LazyListScope.activityListGroupedItems( items: ImmutableList?, @@ -177,15 +209,7 @@ fun LazyListScope.activityListGroupedItems( itemsIndexed( items = groupedItems, key = { index, item -> - when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.scopedId()}" - is Activity.Onchain -> "onchain_${item.scopedId()}" - } - - else -> "item_$index" - } + groupedActivityKey(index, item) }, ) { index, item -> when (item) { @@ -264,7 +288,7 @@ fun LazyListScope.activityListGroupedItems( // region utils @Suppress("CyclomaticComplexMethod") -private fun groupActivityItems(activityItems: List): List { +private fun groupActivityItems(activityItems: List, usdtItems: List = emptyList()): List { val now = Instant.now() val zoneId = ZoneId.systemDefault() val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) @@ -276,18 +300,24 @@ private fun groupActivityItems(activityItems: List): List { val startOfMonth = today.withDayOfMonth(1).toInstant().epochSecond val startOfYear = today.withDayOfYear(1).toInstant().epochSecond - val todayItems = mutableListOf() - val yesterdayItems = mutableListOf() - val weekItems = mutableListOf() - val monthItems = mutableListOf() - val yearItems = mutableListOf() - val earlierItems = mutableListOf() + val todayItems = mutableListOf() + val yesterdayItems = mutableListOf() + val weekItems = mutableListOf() + val monthItems = mutableListOf() + val yearItems = mutableListOf() + val earlierItems = mutableListOf() - for (item in activityItems) { - val timestamp = when (item) { - is Activity.Lightning -> item.v1.timestamp.toLong() - is Activity.Onchain -> item.v1.timestamp.toLong() + val entries = ( + activityItems.map { + WalletActivity.Bitcoin(it) + } + usdtItems.map { WalletActivity.Usdt(it) } + ).sortedByDescending { it.timestamp } + for (entry in entries) { + val item: Any = when (entry) { + is WalletActivity.Bitcoin -> entry.item + is WalletActivity.Usdt -> entry.item } + val timestamp = entry.timestamp.toLong() when { timestamp >= startOfDay -> todayItems.add(item) timestamp >= startOfYesterday -> yesterdayItems.add(item) @@ -299,29 +329,18 @@ private fun groupActivityItems(activityItems: List): List { } return buildList { - if (todayItems.isNotEmpty()) { - add("TODAY") - addAll(todayItems) - } - if (yesterdayItems.isNotEmpty()) { - add("YESTERDAY") - addAll(yesterdayItems) - } - if (weekItems.isNotEmpty()) { - add("THIS WEEK") - addAll(weekItems) - } - if (monthItems.isNotEmpty()) { - add("THIS MONTH") - addAll(monthItems) - } - if (yearItems.isNotEmpty()) { - add("THIS YEAR") - addAll(yearItems) - } - if (earlierItems.isNotEmpty()) { - add("EARLIER") - addAll(earlierItems) + listOf( + "TODAY" to todayItems, + "YESTERDAY" to yesterdayItems, + "THIS WEEK" to weekItems, + "THIS MONTH" to monthItems, + "THIS YEAR" to yearItems, + "EARLIER" to earlierItems, + ).forEach { (title, items) -> + if (items.isNotEmpty()) { + add(title) + addAll(items) + } } } } @@ -365,3 +384,17 @@ private fun PreviewEmptyWithFooter() { ) } } + +fun activityGroupTitleResource(timestamp: ULong): Int { + val today = Instant.now().atZone(ZoneId.systemDefault()).truncatedTo(ChronoUnit.DAYS) + val week = today.with(TemporalAdjusters.previousOrSame(WeekFields.of(Locale.getDefault()).firstDayOfWeek)) + val date = Instant.ofEpochSecond(timestamp.toLong()) + return when { + date >= today.toInstant() -> R.string.wallet__activity_group_today + date >= today.minusDays(1).toInstant() -> R.string.wallet__activity_group_yesterday + date >= week.toInstant() -> R.string.wallet__activity_group_week + date >= today.withDayOfMonth(1).toInstant() -> R.string.wallet__activity_group_month + date >= today.withDayOfYear(1).toInstant() -> R.string.wallet__activity_group_year + else -> R.string.wallet__activity_group_earlier + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index d52ce7970a..f5737c79f4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -15,16 +15,21 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.UsdtTransfer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.scopedId +import to.bitkit.ext.timestamp import to.bitkit.ui.activityListViewModel +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.UsdtActivityRow +import to.bitkit.ui.settingsViewModel import to.bitkit.ui.theme.AppThemeSurface @Composable @@ -33,8 +38,17 @@ fun ActivityListSimple( onAllActivityClick: () -> Unit, onActivityItemClick: (Activity) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), + usdtItems: ImmutableList = persistentListOf(), + maxItems: Int = 4, ) { - if (items.isNullOrEmpty()) return + if (items.isNullOrEmpty() && usdtItems.isEmpty()) return + val app = appViewModel + val settings = settingsViewModel + val hideBalance by settings?.hideBalance?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } + val rows = remember(items, usdtItems, maxItems) { + (items.orEmpty().map { WalletActivity.Bitcoin(it) } + usdtItems.map { WalletActivity.Usdt(it) }) + .sortedByDescending { it.timestamp }.take(maxItems) + } val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(persistentListOf()) @@ -44,18 +58,23 @@ fun ActivityListSimple( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth() ) { - items.forEachIndexed { index, item -> - ActivityRow( - item = item, - onClick = onActivityItemClick, - testTag = "ActivityShort-$index", - title = contactActivityTitle(item, contacts), - isHardware = item.scopedId() in hardwareIds, - contact = contactForActivity(item, contacts), - ) - if (index < items.lastIndex) { - VerticalSpacer(16.dp) + rows.forEachIndexed { index, entry -> + when (entry) { + is WalletActivity.Bitcoin -> ActivityRow( + item = entry.item, + onClick = onActivityItemClick, + testTag = "ActivityShort-$index", + title = contactActivityTitle(entry.item, contacts), + isHardware = entry.item.scopedId() in hardwareIds, + contact = contactForActivity(entry.item, contacts), + ) + is WalletActivity.Usdt -> UsdtActivityRow( + entry.item, + hideBalance, + onClick = { app?.navigateToUsdtActivity(entry.item.id) } + ) } + if (index < rows.lastIndex) VerticalSpacer(16.dp) } TertiaryButton( text = stringResource(R.string.wallet__activity_show_all), @@ -90,3 +109,9 @@ private fun PreviewEmpty() { ) } } + +internal sealed interface WalletActivity { + val timestamp: ULong + data class Bitcoin(val item: Activity) : WalletActivity { override val timestamp get() = item.timestamp() } + data class Usdt(val item: UsdtTransfer) : WalletActivity { override val timestamp get() = item.timestamp } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index b2cfa5f6c9..ef0f53e471 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -19,9 +20,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity @@ -53,6 +56,7 @@ import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.animations.BalanceAnimations import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Shapes @@ -104,15 +108,7 @@ fun ActivityRow( } } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickableAlpha { onClick(item) } - .background(color = Colors.Gray6, shape = Shapes.medium) - .padding(16.dp) - .testTag(testTag) - ) { + ActivityRowSurface(onClick = { onClick(item) }, modifier = Modifier.testTag(testTag)) { ActivityIcon( activity = item, size = 40.dp, @@ -294,7 +290,7 @@ private fun AmountView( } @Composable -private fun AmountViewContent( +fun AmountViewContent( title: String, titlePrefix: String, subtitle: String, @@ -303,20 +299,27 @@ private fun AmountViewContent( subtitleSymbol: String? = null, isSymbolSuffix: Boolean = false, hideBalance: Boolean = false, + titleStyle: TextStyle = AppTextStyles.BodyMSB, + subtitleStyle: TextStyle = AppTextStyles.CaptionB, + verticalSpacing: Dp = 2.dp, ) { Column( modifier = modifier, horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(verticalSpacing), ) { // Title row with static prefix and symbol Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(1.dp), ) { - BodyM(text = titlePrefix, color = Colors.White64) + BodyM( + text = titlePrefix, + color = Colors.White64, + style = titleStyle.copy(fontWeight = AppTextStyles.BodyM.fontWeight) + ) if (titleSymbol != null && !isSymbolSuffix) { - BodyMSB(text = titleSymbol, color = Colors.White64) + BodyMSB(text = titleSymbol, color = Colors.White64, style = titleStyle) } HorizontalSpacer(2.dp) AnimatedContent( @@ -324,11 +327,12 @@ private fun AmountViewContent( transitionSpec = { BalanceAnimations.activityAmountTransition }, label = "titleAnimation" ) { isHidden -> - BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else title) + BodyMSB(text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else title, style = titleStyle) } if (titleSymbol != null && isSymbolSuffix) { BodyMSB( text = titleSymbol, + style = titleStyle, color = Colors.White64, modifier = Modifier.padding(start = 2.dp), ) @@ -341,7 +345,7 @@ private fun AmountViewContent( horizontalArrangement = Arrangement.spacedBy(3.dp), ) { if (subtitleSymbol != null && !isSymbolSuffix) { - CaptionB(text = subtitleSymbol, color = Colors.White64) + CaptionB(text = subtitleSymbol, color = Colors.White64, style = subtitleStyle) } AnimatedContent( targetState = hideBalance, @@ -350,11 +354,12 @@ private fun AmountViewContent( ) { isHidden -> CaptionB( text = if (isHidden) UiConstants.HIDE_BALANCE_SHORT else subtitle, + style = subtitleStyle, color = Colors.White64, ) } if (subtitleSymbol != null && isSymbolSuffix) { - CaptionB(text = subtitleSymbol, color = Colors.White64) + CaptionB(text = subtitleSymbol, color = Colors.White64, style = subtitleStyle) } } } @@ -378,3 +383,17 @@ private fun Preview(@PreviewParameter(ActivityItemsPreviewProvider::class) item: ) } } + +@Composable +fun ActivityRowSurface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + content = content, + modifier = modifier.fillMaxWidth().clickableAlpha(onClick = onClick) + .background(Colors.Gray6, Shapes.medium).padding(16.dp) + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 7e47911a3a..2f9f67a1c7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -191,7 +191,7 @@ private fun ReceiveTab.toReceiveLiquiditySource(): ReceiveLiquiditySource { ReceiveTab.SAVINGS -> SAVINGS ReceiveTab.AUTO -> AUTO ReceiveTab.SPENDING -> SPENDING - ReceiveTab.HARDWARE -> SAVINGS + ReceiveTab.HARDWARE, ReceiveTab.USDT -> SAVINGS } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt index e4e5e197ad..78e9ceeae1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt @@ -43,6 +43,7 @@ fun getInvoiceForTab( ?: bolt11.takeIf { isNodeRunning && canCreateLightningInvoice }.orEmpty() } + ReceiveTab.USDT -> "" ReceiveTab.HARDWARE -> hardwareAddress.takeIf(String::isNotBlank)?.let { address -> Bip21Utils.buildBip21Url( bitcoinAddress = address, @@ -107,6 +108,7 @@ fun getQrLogoResource(tab: ReceiveTab): Int { ReceiveTab.SAVINGS -> R.drawable.ic_btc_circle ReceiveTab.AUTO -> R.drawable.ic_unified_circle ReceiveTab.SPENDING -> R.drawable.ic_ln_circle + ReceiveTab.USDT -> R.drawable.tether_circle ReceiveTab.HARDWARE -> R.drawable.ic_btc_circle_blue } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 71221db7f3..7816079dc4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,6 +51,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList @@ -64,6 +67,7 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletState +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview @@ -79,6 +83,7 @@ import to.bitkit.ui.components.Tooltip import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing +import to.bitkit.ui.screens.wallets.usdt.UsdtDepositReceiveScreen import to.bitkit.ui.shared.effects.SetMaxBrightness import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground @@ -101,6 +106,9 @@ fun ReceiveQrScreen( onClickHardwareEditInvoice: () -> Unit = { onClickEditInvoice(ReceiveTab.HARDWARE) }, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, + onClose: () -> Unit = {}, + onBlockingChange: (Boolean) -> Unit = {}, + onUsdtSelected: (Boolean) -> Unit = {}, hardwareWalletId: String? = null, /** Vendor name shown on the hardware tab, e.g. "Trezor" or "Jade". */ hardwareTabLabel: String? = null, @@ -132,7 +140,9 @@ fun ReceiveQrScreen( var showDetails by remember { mutableStateOf(false) } - val visibleTabs = remember(canCreateLightningInvoice, cjitInvoice, hardwareWalletId) { + val usdtEnabled by appViewModel?.isUsdtEnabled?.collectAsStateWithLifecycle() + ?: remember { mutableStateOf(false) } + val visibleTabs = remember(canCreateLightningInvoice, cjitInvoice, hardwareWalletId, usdtEnabled) { buildList { if (hardwareWalletId != null) { add(ReceiveTab.HARDWARE) @@ -142,6 +152,7 @@ fun ReceiveQrScreen( add(ReceiveTab.AUTO) } add(ReceiveTab.SPENDING) + if (usdtEnabled && cjitInvoice == null) add(ReceiveTab.USDT) }.toImmutableList() } val defaultTab = remember(visibleTabs, initialTab) { @@ -214,7 +225,9 @@ fun ReceiveQrScreen( } LaunchedEffect(canCreateLightningInvoice, cjitInvoice, initialTab) { - if (initialTab == ReceiveTab.HARDWARE) return@LaunchedEffect + if (initialTab == ReceiveTab.HARDWARE || initialTab == ReceiveTab.USDT || selectedTab == ReceiveTab.USDT) { + return@LaunchedEffect + } if (!canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { selectedTab = ReceiveTab.SAVINGS lazyListState.scrollToItem(visibleTabs.indexOf(ReceiveTab.SAVINGS).coerceAtLeast(0)) @@ -225,7 +238,7 @@ fun ReceiveQrScreen( snapshotFlow { lazyListState.firstVisibleItemIndex } .distinctUntilChanged() .collect { index -> - if (index < visibleTabs.size && index > -1) { + if (selectedTab != ReceiveTab.USDT && index < visibleTabs.size && index > -1) { val tab = visibleTabs[index] selectedTab = tab } @@ -278,6 +291,35 @@ fun ReceiveQrScreen( cjitInvoice.isNullOrEmpty() } + fun selectReceiveTab(tab: ReceiveTab) { + selectedTab = tab + hasUserSelectedTab = true + scope.launch { lazyListState.scrollToItem(visibleTabs.indexOf(tab)) } + } + + DisposableEffect(selectedTab) { + onUsdtSelected(selectedTab == ReceiveTab.USDT) + onDispose { onUsdtSelected(false) } + } + + if (selectedTab == ReceiveTab.USDT) { + Column( + modifier = modifier.fillMaxSize().background( + Colors.Gray7 + ).navigationBarsPadding().padding(horizontal = 16.dp) + ) { + UsdtDepositReceiveScreen( + viewModel = hiltViewModel(), + tabs = visibleTabs, + onSelectTab = ::selectReceiveTab, + onBack = onClose, + onBlockingChange = onBlockingChange, + onContacts = onClickPaymentRequestContacts.takeIf { showPaymentRequestContacts }, + ) + } + return + } + Column( modifier = modifier .fillMaxSize() @@ -404,7 +446,7 @@ fun ReceiveQrScreen( } else { { onClickEditInvoice(tab) } }, - tab = tab, + accentColor = tab.accentColor, modifier = Modifier.fillMaxWidth() ) } @@ -510,13 +552,14 @@ internal fun shouldAutoSwitchToAuto( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ReceiveQrView( +fun ReceiveQrView( uri: String, copyText: String, - qrLogoPainter: Painter, - onClickEditInvoice: () -> Unit, - tab: ReceiveTab, + qrLogoPainter: Painter?, + onClickEditInvoice: (() -> Unit)?, + accentColor: Color, modifier: Modifier = Modifier, + shareContent: String = copyText, ) { val context = LocalContext.current val qrButtonTooltipState = rememberTooltipState() @@ -542,23 +585,25 @@ private fun ReceiveQrView( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top, ) { - PrimaryButton( - text = stringResource(R.string.common__edit), - size = ButtonSize.Small, - onClick = onClickEditInvoice, - fullWidth = false, - icon = { - Icon( - painter = painterResource(R.drawable.ic_pencil_simple), - contentDescription = null, - tint = tab.accentColor, - modifier = Modifier.size(18.dp) - ) - }, - modifier = Modifier - .weight(1f) - .testTag("SpecifyInvoiceButton") - ) + if (onClickEditInvoice != null) { + PrimaryButton( + text = stringResource(R.string.common__edit), + size = ButtonSize.Small, + onClick = onClickEditInvoice, + fullWidth = false, + icon = { + Icon( + painter = painterResource(R.drawable.ic_pencil_simple), + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(18.dp) + ) + }, + modifier = Modifier + .weight(1f) + .testTag("SpecifyInvoiceButton") + ) + } Box(modifier = Modifier.weight(1f)) { Tooltip( text = stringResource(R.string.wallet__receive_copied), @@ -576,7 +621,7 @@ private fun ReceiveQrView( Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = null, - tint = tab.accentColor, + tint = accentColor, modifier = Modifier.size(18.dp) ) }, @@ -590,15 +635,15 @@ private fun ReceiveQrView( size = ButtonSize.Small, onClick = { qrBitmap?.let { bitmap -> - shareQrCode(context, bitmap, copyText) - } ?: shareText(context, copyText) + shareQrCode(context, bitmap, shareContent) + } ?: shareText(context, shareContent) }, fullWidth = false, icon = { Icon( painter = painterResource(R.drawable.ic_share), contentDescription = null, - tint = tab.accentColor, + tint = accentColor, modifier = Modifier.size(18.dp) ) }, @@ -738,6 +783,7 @@ private fun ReceiveDetailsView( } } + ReceiveTab.USDT -> Unit ReceiveTab.HARDWARE -> { hardwareAddress?.let { address -> CopyAddressCard( @@ -791,14 +837,15 @@ enum class CopyAddressType { ONCHAIN, LIGHTNING } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CopyAddressCard( +fun CopyAddressCard( title: String, address: String, type: CopyAddressType, - onClickEditInvoice: () -> Unit, + onClickEditInvoice: (() -> Unit)?, body: String? = null, testTag: String? = null, accentColor: Color? = null, + shareContent: String = address, ) { val context = LocalContext.current @@ -825,51 +872,55 @@ private fun CopyAddressCard( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - PrimaryButton( - text = stringResource(R.string.common__edit), - size = ButtonSize.Small, - onClick = onClickEditInvoice, - fullWidth = false, - icon = { - Icon( - painter = painterResource(R.drawable.ic_pencil_simple), - contentDescription = null, - tint = buttonAccentColor, - modifier = Modifier.size(18.dp) - ) - }, - modifier = Modifier - .weight(1f) - .testTag("SpecifyInvoiceButton") - ) - Tooltip( - text = stringResource(R.string.wallet__receive_copied), - tooltipState = tooltipState, - ) { - Box(modifier = Modifier.weight(1f)) { - PrimaryButton( - text = stringResource(R.string.common__copy), - size = ButtonSize.Small, - onClick = { - context.setClipboardText(address) - coroutineScope.launch { tooltipState.show() } - }, - fullWidth = false, - icon = { - Icon( - painter = painterResource(R.drawable.ic_copy), - contentDescription = null, - tint = buttonAccentColor, - modifier = Modifier.size(18.dp) - ) - }, - ) + if (onClickEditInvoice != null) { + PrimaryButton( + text = stringResource(R.string.common__edit), + size = ButtonSize.Small, + onClick = onClickEditInvoice, + fullWidth = false, + icon = { + Icon( + painter = painterResource(R.drawable.ic_pencil_simple), + contentDescription = null, + tint = buttonAccentColor, + modifier = Modifier.size(18.dp) + ) + }, + modifier = Modifier + .weight(1f) + .testTag("SpecifyInvoiceButton") + ) + } + Box(modifier = Modifier.weight(1f)) { + Tooltip( + text = stringResource(R.string.wallet__receive_copied), + tooltipState = tooltipState + ) { + Box { + PrimaryButton( + text = stringResource(R.string.common__copy), + size = ButtonSize.Small, + onClick = { + context.setClipboardText(address) + coroutineScope.launch { tooltipState.show() } + }, + fullWidth = true, + icon = { + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = null, + tint = buttonAccentColor, + modifier = Modifier.size(18.dp) + ) + }, + ) + } } } PrimaryButton( text = stringResource(R.string.common__share), size = ButtonSize.Small, - onClick = { shareText(context, address) }, + onClick = { shareText(context, shareContent) }, fullWidth = false, icon = { Icon( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index e71d3e1bb1..81fed8787b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -28,6 +28,8 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.LightningState import to.bitkit.repositories.PaykitPaymentRequest @@ -66,6 +68,9 @@ fun ReceiveSheet( walletState: WalletState, isOffline: Boolean, startRoute: ReceiveRoute = ReceiveRoute.QR, + initialTab: ReceiveTab? = null, + onBlockingChange: (Boolean) -> Unit = {}, + onUsdtSelected: (Boolean) -> Unit = {}, hardwareWalletId: String? = null, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), paymentRequestAmountViewModel: AmountInputViewModel = hiltViewModel(key = "PaymentRequestAmount"), @@ -93,7 +98,7 @@ fun ReceiveSheet( var paymentRequestDraft by remember { mutableStateOf( PaykitPaymentRequestDraft( - amountSats = 0uL, + amount = PaykitAmount(PaykitAsset.BTC, 0uL), note = "", expiresAt = Clock.System.now() + 7.days, ) @@ -149,6 +154,7 @@ fun ReceiveSheet( ) { composableWithDefaultTransitions { ReceiveQrScreen( + onUsdtSelected = onUsdtSelected, cjitInvoice = cjitSessionState.cjitInvoice, walletState = walletState, lightningState = lightningState, @@ -172,7 +178,9 @@ fun ReceiveSheet( resetEditInvoiceAmount() navController.navigateTo(ReceiveRoute.EditInvoice) }, - initialTab = invoiceEditState.initialTab(hardwareWalletId), + initialTab = invoiceEditState.initialTab(hardwareWalletId) ?: initialTab, + onBlockingChange = onBlockingChange, + onClose = appViewModel::hideSheet, hardwareWalletId = selectedHardwareWalletId, hardwareTabLabel = hardwareWallets.firstOrNull { it.id == selectedHardwareWalletId } ?.let { stringResource(it.vendor.modelNameRes()) }, @@ -183,7 +191,8 @@ fun ReceiveSheet( showPaymentRequestContacts = paymentRequestTargets.isNotEmpty(), onClickPaymentRequestContacts = { paymentRequestDraft = paymentRequestDraft.copy( - amountSats = 0uL, + amount = PaykitAmount(PaykitAsset.BTC, 0uL), + acceptedPaymentEndpointIdentifiers = null, note = "", expiresAt = Clock.System.now() + 7.days, ) @@ -390,7 +399,7 @@ fun ReceiveSheet( showPaymentRequestButton = paymentRequestTargets.isNotEmpty(), onClickPaymentRequest = { amountSats, note -> paymentRequestDraft = PaykitPaymentRequestDraft( - amountSats = amountSats, + amount = PaykitAmount(PaykitAsset.BTC, amountSats), note = note, expiresAt = Clock.System.now() + 7.days, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt index 70186028af..d5ffa4ffa9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt @@ -11,6 +11,7 @@ enum class ReceiveTab : TabItem { SAVINGS, AUTO, SPENDING, + USDT, /** The paired hardware wallet; its label carries the vendor name (see [ReceiveQrScreen]). */ HARDWARE; @@ -21,6 +22,7 @@ enum class ReceiveTab : TabItem { SAVINGS -> stringResource(R.string.wallet__receive_tab_savings) AUTO -> stringResource(R.string.wallet__receive_tab_auto) SPENDING -> stringResource(R.string.wallet__receive_tab_spending) + USDT -> "USDT" HARDWARE -> stringResource(R.string.hardware__receive_tab_hardware) } @@ -29,6 +31,7 @@ enum class ReceiveTab : TabItem { SAVINGS -> Colors.Brand AUTO -> Colors.Brand SPENDING -> Colors.Purple + USDT -> Colors.Usdt HARDWARE -> Colors.Blue } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt index a1c472b3ec..2a84389d20 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -16,22 +15,18 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.PaymentAddressInput import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.TextInput 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.AppTextFieldDefaults -import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.TRANSITION_SCREEN_MS @@ -69,27 +64,11 @@ fun SendAddressScreen( color = Colors.White64, ) VerticalSpacer(8.dp) - TextInput( - placeholder = stringResource(R.string.wallet__send_address_placeholder), + PaymentAddressInput( value = uiState.addressInput, onValueChange = { onEvent(SendEvent.AddressChange(it)) }, - minLines = 12, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done, - autoCorrectEnabled = false - ), - textStyle = AppTextStyles.Title, - colors = AppTextFieldDefaults.semiTransparent.copy( - focusedContainerColor = Colors.White06, - unfocusedContainerColor = Colors.White06, - errorContainerColor = Colors.White06, - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .weight(1f) - .testTag("RecipientInput") + placeholder = stringResource(R.string.wallet__send_address_placeholder), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).weight(1f).testTag("RecipientInput") ) VerticalSpacer(16.dp) PrimaryButton( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index b24890b525..dda7333e32 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -1,5 +1,9 @@ package to.bitkit.ui.screens.wallets.send +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,17 +12,29 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -26,45 +42,67 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData +import com.synonym.bitkitcore.UsdtDestination +import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.models.BalanceState import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PrimaryDisplay import to.bitkit.models.Toast import to.bitkit.models.safe import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalBalances import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.HwWalletConnectionIcon +import to.bitkit.ui.components.MoneyMSB import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadTextField +import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.UnitButton +import to.bitkit.ui.components.UsdtAmountHeader import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.WalletBalanceContent +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.screens.wallets.usdt.UsdtNetworkButton +import to.bitkit.ui.settingsViewModel +import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.modifiers.clickableAlpha 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.ui.utils.NumberPadInputHandler +import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AmountInputEffect import to.bitkit.viewmodels.AmountInputUiState import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.LnurlParams import to.bitkit.viewmodels.SendEvent +import to.bitkit.viewmodels.SendFundingOption +import to.bitkit.viewmodels.SendFundingSource import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.previewAmountInputViewModel +/** Opacity of unavailable payment sources in the balance selector. */ +private const val DISABLED_FUNDING_SOURCE_ALPHA = 0.30f + @Suppress("ViewModelForwarding") @Composable fun SendAmountScreen( @@ -98,14 +136,19 @@ fun SendAmountScreen( } val currentMaxExceededMessage by rememberUpdatedState(maxExceededMessage) - LaunchedEffect(Unit) { - if (uiState.amount > 0u) { + val pendingRequests = app?.pendingPaymentRequests?.collectAsStateWithLifecycle()?.value + LaunchedEffect(pendingRequests, uiState.paykitUsesUsdt) { + app?.refreshPaykitRequestAmount() + } + + LaunchedEffect(uiState.paykitUsesUsdt, uiState.payMethod, uiState.amount.takeIf { uiState.isPaymentRequest }) { + if (!uiState.paykitUsesUsdt && uiState.amount > 0u) { amountInputViewModel.setSats(uiState.amount.toLong(), currencies) } } LaunchedEffect(amountInputUiState.sats) { - currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong())) + if (!uiState.paykitUsesUsdt) currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong())) } LaunchedEffect(Unit) { @@ -150,7 +193,7 @@ fun SendAmountScreen( } amountInputViewModel.setSats(maxSats, currencies) }, - onClickPayMethod = { onEvent(SendEvent.PaymentMethodSwitch) }, + onClickPayMethod = { onEvent(SendEvent.PaymentMethodSelect(it)) }, onContinue = { onEvent(SendEvent.AmountContinue) }, ) } @@ -166,20 +209,20 @@ fun SendAmountContent( currencies: CurrencyState = LocalCurrencies.current, onBack: (() -> Unit)? = {}, onClickMax: (Long) -> Unit = {}, - onClickPayMethod: () -> Unit = {}, + onClickPayMethod: (SendFundingSource) -> Unit = {}, onContinue: () -> Unit = {}, ) { Column( modifier = modifier .fillMaxSize() - .gradientBackground() + .then(if (uiState.paykitUsesUsdt) Modifier.background(Colors.Gray7) else Modifier.gradientBackground()) .navigationBarsPadding() .testTag("send_amount_screen") ) { val titleRes = when (uiState.lnurl) { is LnurlParams.LnurlWithdraw -> R.string.wallet__lnurl_w_title is LnurlParams.LnurlPay -> R.string.wallet__lnurl_p_title - else -> R.string.wallet__send_amount + else -> if (uiState.paykitUsesUsdt) R.string.usdt__amount else R.string.wallet__send_amount } SendContactTopBar( @@ -188,8 +231,8 @@ fun SendAmountContent( onBack = onBack, ) - when (nodeLifecycleState) { - is NodeLifecycleState.Running -> { + when { + nodeLifecycleState is NodeLifecycleState.Running || uiState.paykitUsesUsdt -> { SendAmountNodeRunning( amountInputViewModel = amountInputViewModel, uiState = uiState, @@ -220,10 +263,13 @@ private fun SendAmountNodeRunning( uiState: SendUiState, balances: BalanceState, currencies: CurrencyState, - onClickPayMethod: () -> Unit, + onClickPayMethod: (SendFundingSource) -> Unit, onClickMax: (Long) -> Unit, onContinue: () -> Unit, ) { + val app = appViewModel + val usdtWallet = app?.usdtWallet?.collectAsStateWithLifecycle()?.value + val hideBalance = settingsViewModel?.hideBalance?.collectAsStateWithLifecycle()?.value == true BoxWithConstraints { val maxHeight = this.maxHeight val isLnurlWithdraw = uiState.lnurl is LnurlParams.LnurlWithdraw @@ -253,17 +299,31 @@ private fun SendAmountNodeRunning( ) { VerticalSpacer(16.dp) - NumberPadTextField( - viewModel = amountInputViewModel, - modifier = Modifier - .fillMaxWidth() - .testTag("SendNumberField") - ) + if (uiState.paykitUsesUsdt) { + UsdtAmountHeader( + amount = uiState.paykitAmount.ifEmpty { "0" }, + modifier = Modifier.testTag("SendNumberField") + ) + } else { + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier + .fillMaxWidth() + .testTag("SendNumberField") + ) + } FillHeight(min = 12.dp) + if (uiState.paykitRateUnavailable) { + BodyS( + stringResource(R.string.wallet__payment_request_rate_unavailable), + color = Colors.White64 + ) + } val textAvailable = when { uiState.lnurl is LnurlParams.LnurlWithdraw -> R.string.wallet__lnurl_w_max + uiState.paykitUsesUsdt -> R.string.wallet__send_available uiState.hardwareWalletId != null -> R.string.wallet__send_available uiState.isUnified -> R.string.wallet__send_available uiState.payMethod == SendMethod.ONCHAIN -> R.string.wallet__send_available @@ -276,7 +336,9 @@ private fun SendAmountNodeRunning( ) { Column( modifier = Modifier - .clickableAlpha { onClickMax(availableAmount) } + .clickableAlpha( + enabled = uiState.isAmountEditable && !uiState.paykitUsesUsdt + ) { onClickMax(availableAmount) } .testTag("AvailableAmount") ) { Text13Up( @@ -285,16 +347,29 @@ private fun SendAmountNodeRunning( modifier = Modifier.testTag("available_balance") ) VerticalSpacer(4.dp) - MoneySSB(sats = availableAmount, showSymbol = true) + if (uiState.paykitUsesUsdt) { + val balance = if (hideBalance) { + UiConstants.HIDE_BALANCE_SHORT + } else { + usdtWallet?.balance?.let { "$ " + usdtOverviewAmount(it) } ?: "—" + } + BodySSB(balance.withAccent(accentColor = Colors.White64)) + } else { + MoneySSB(sats = availableAmount, showSymbol = true) + } } FillWidth() val isLnurl = uiState.lnurl != null - if (!isLnurl) { + if (!isLnurl || uiState.usdtRecipient != null) { PaymentMethodButton(uiState = uiState, onClick = onClickPayMethod) } - if (uiState.lnurl is LnurlParams.LnurlPay) { + if (uiState.paykitUsesUsdt) { + HorizontalSpacer(8.dp) + UsdtNetworkButton(UsdtDestination.ARBITRUM, null, persistentListOf(UsdtDestination.ARBITRUM)) + } + if (!uiState.isPaymentRequest && !uiState.paykitUsesUsdt && uiState.lnurl is LnurlParams.LnurlPay) { val max = minOf( uiState.lnurl.data.maxSendableSat().toLong(), availableAmount, @@ -308,24 +383,42 @@ private fun SendAmountNodeRunning( ) } HorizontalSpacer(8.dp) - UnitButton( - onClick = { amountInputViewModel.switchUnit(currencies) }, - modifier = Modifier - .height(28.dp) - .testTag("SendNumberPadUnit") - ) + if (!uiState.paykitUsesUsdt) { + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier + .height(28.dp) + .testTag("SendNumberPadUnit") + ) + } } HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) - NumberPad( - viewModel = amountInputViewModel, - currencies = currencies, - availableHeight = maxHeight, - modifier = Modifier - .testTag("SendAmountNumberPad") - ) + if (uiState.paykitUsesUsdt) { + NumberPad( + type = NumberPadType.DECIMAL, + availableHeight = maxHeight, + enabled = uiState.isAmountEditable, + onPress = { + app?.onUsdtAmountChange( + NumberPadInputHandler.handleInput(it, uiState.paykitAmount, maxLength = 21, maxDecimals = 6) + ) + }, + onDeleteLongPress = { app?.onUsdtAmountChange("") } + ) + } else { + NumberPad( + enabled = uiState.isAmountEditable, + viewModel = amountInputViewModel, + currencies = currencies, + availableHeight = maxHeight, + modifier = Modifier + .testTag("SendAmountNumberPad") + ) + } + if (uiState.paykitUsesUsdt) VerticalSpacer(24.dp) PrimaryButton( text = stringResource(R.string.common__continue), enabled = uiState.isAmountInputValid && !uiState.isFundingSourceLoading, @@ -342,37 +435,167 @@ private fun SendAmountNodeRunning( @Composable private fun PaymentMethodButton( uiState: SendUiState, - onClick: () -> Unit, + onClick: (SendFundingSource) -> Unit, ) { + var expanded by remember { mutableStateOf(false) } val isHardware = uiState.hardwareWalletId != null val testId = when { uiState.canSwitchFundingSource -> "switch" + uiState.paykitUsesUsdt -> "usdt" isHardware -> "trezor" uiState.payMethod == SendMethod.ONCHAIN -> "savings" else -> "spending" } - NumberPadActionButton( - text = when { - isHardware -> uiState.hardwareWalletName ?: stringResource(R.string.hardware__device_model_trezor) - uiState.payMethod == SendMethod.ONCHAIN -> stringResource(R.string.wallet__savings__title) - else -> stringResource(R.string.wallet__spending__title) - }, - color = when { - isHardware -> Colors.Blue - uiState.payMethod == SendMethod.ONCHAIN -> Colors.Brand - else -> Colors.Purple - }, - icon = if (uiState.canSwitchFundingSource) R.drawable.ic_transfer else null, - onClick = onClick, - enabled = uiState.canSwitchFundingSource, - isLoading = uiState.isFundingSourceLoading, - clickable = !uiState.isLoading, - modifier = Modifier - .height(28.dp) - .testTag("AssetButton-$testId") + Box { + NumberPadActionButton( + text = when { + uiState.paykitUsesUsdt -> "USDT" + isHardware -> uiState.hardwareWalletName ?: stringResource(R.string.hardware__device_model_trezor) + uiState.payMethod == SendMethod.ONCHAIN -> stringResource(R.string.wallet__savings__title) + else -> stringResource(R.string.wallet__spending__title) + }, + color = paymentMethodColor(uiState), + icon = if (uiState.canSwitchFundingSource) R.drawable.ic_transfer else null, + onClick = { expanded = true }, + enabled = uiState.fundingOptions.isNotEmpty(), + isLoading = uiState.isFundingSourceLoading, + clickable = !uiState.isLoading, + modifier = Modifier + .height(28.dp) + .testTag("AssetButton-$testId") + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + shape = RoundedCornerShape(16.dp), + containerColor = Colors.Gray6, + modifier = Modifier.width(250.dp) + .semantics { testTagsAsResourceId = true } + .testTag("SendFundingSources") + ) { + VerticalSpacer(4.dp) + uiState.fundingOptions.forEachIndexed { index, option -> + FundingSourceOption(option, uiState, onClick = { + expanded = false + onClick(option.source) + }) + if (index < uiState.fundingOptions.lastIndex) { + HorizontalDivider(Modifier.padding(horizontal = 24.dp), color = Colors.White10) + } + } + VerticalSpacer(4.dp) + } + } +} + +@Composable +private fun FundingSourceOption(option: SendFundingOption, state: SendUiState, onClick: () -> Unit) { + val source = option.source + val title = when (source) { + SendFundingSource.Savings -> stringResource(R.string.wallet__savings__title) + SendFundingSource.Spending -> stringResource(R.string.wallet__spending__title) + SendFundingSource.Usdt -> "USDT" + is SendFundingSource.Hardware -> option.name ?: stringResource(R.string.hardware__device_model_trezor) + } + val color = when (source) { + SendFundingSource.Savings -> Colors.Brand + SendFundingSource.Spending -> Colors.Purple + SendFundingSource.Usdt -> Colors.Usdt + is SendFundingSource.Hardware -> Colors.Blue + } + Row( + Modifier.fillMaxWidth().alpha(if (option.enabled) 1f else DISABLED_FUNDING_SOURCE_ALPHA) + .clickable(enabled = option.enabled && !state.isFundingSourceLoading, onClick = onClick) + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + WalletBalanceContent( + title = title, + icon = { FundingSourceIcon(source) }, + titleTrailing = { + option.transportType?.let { transport -> + HorizontalSpacer(8.dp) + HwWalletConnectionIcon(transport, option.isConnected, modifier = Modifier.size(16.dp)) + } + }, + ) { + FundingSourceBalance(option) + FillWidth() + if (state.isSelected(source)) { + Icon( + painterResource(R.drawable.ic_check), + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp) + ) + } + } + } +} + +@Composable +private fun FundingSourceIcon(source: SendFundingSource) { + Image( + painterResource( + when (source) { + SendFundingSource.Savings -> R.drawable.ic_btc_circle + SendFundingSource.Spending -> R.drawable.ic_ln_circle + SendFundingSource.Usdt -> R.drawable.tether_circle + is SendFundingSource.Hardware -> R.drawable.ic_btc_circle_blue + } + ), + contentDescription = null, + modifier = Modifier.padding(end = 4.dp).size(24.dp) ) } +@Composable +private fun FundingSourceBalance(option: SendFundingOption) { + val balances = LocalBalances.current + val usdt = appViewModel?.usdtWallet?.collectAsStateWithLifecycle()?.value + val hidden = settingsViewModel?.hideBalance?.collectAsStateWithLifecycle()?.value == true + val source = option.source + if (source == SendFundingSource.Usdt) { + BodyMSB( + if (hidden) UiConstants.HIDE_BALANCE_SHORT else usdt?.balance?.let { usdtOverviewAmount(it) } ?: "—" + ) + } else { + MoneyMSB( + sats = when (source) { + SendFundingSource.Savings -> balances.maxSendOnchainSats.toLong() + SendFundingSource.Spending -> balances.maxSendLightningSats.toLong() + else -> option.balance.toLong() + }, + unit = PrimaryDisplay.BITCOIN, + showSymbol = false, + ) + } + if (source == SendFundingSource.Spending) { + HorizontalSpacer(4.dp) + Icon( + painterResource(R.drawable.ic_transfer), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp) + ) + } +} + +private fun SendUiState.isSelected(source: SendFundingSource): Boolean = when (source) { + SendFundingSource.Usdt -> paykitUsesUsdt + SendFundingSource.Spending -> + !paykitUsesUsdt && hardwareWalletId == null && payMethod == SendMethod.LIGHTNING + SendFundingSource.Savings -> + !paykitUsesUsdt && hardwareWalletId == null && payMethod == SendMethod.ONCHAIN + is SendFundingSource.Hardware -> hardwareWalletId == source.walletId +} + +private fun paymentMethodColor(uiState: SendUiState): Color = when { + uiState.paykitUsesUsdt -> Colors.Usdt + uiState.hardwareWalletId != null -> Colors.Blue + uiState.payMethod == SendMethod.ONCHAIN -> Colors.Brand + else -> Colors.Purple +} + @Preview(showSystemUi = true) @Composable private fun PreviewLightningNoAmount() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 2ab742051e..525d3cda1d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -1,10 +1,7 @@ package to.bitkit.ui.screens.wallets.send -import androidx.annotation.DrawableRes import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -26,7 +23,6 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,9 +36,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -66,6 +59,7 @@ import to.bitkit.R import to.bitkit.ext.commentAllowed import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate +import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.PaykitPaymentRequest @@ -79,6 +73,10 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.PaykitAmountDisplay +import to.bitkit.ui.components.PaymentRequestInvoiceNote +import to.bitkit.ui.components.PaymentRequestSummary +import to.bitkit.ui.components.PaymentReviewIllustration import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.SendCell @@ -86,9 +84,7 @@ import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.TextInput -import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.ZigzagDivider import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.settingsViewModel @@ -109,8 +105,6 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds private val EXPIRY_REFRESH_INTERVAL = 60.seconds -private const val SWIPE_ROTATION_DEGREES = 14f -private const val IMAGE_FILL_PERCENTAGE = 0.8f const val SEND_CONFIRM_RESET_RESULT_KEY = "SEND_CONFIRM_RESET_RESULT_KEY" @Suppress("MagicNumber") @@ -374,15 +368,26 @@ private fun ContentRunning( .padding(horizontal = 16.dp) .fillMaxSize() ) { - BalanceHeaderView( - sats = (preparingRequest?.amountSats ?: uiState.amount).toLong(), - useSwipeToHide = false, - onClick = { onEvent(SendEvent.BackToAmount) }.takeUnless { isPreparing }, - testTag = "ReviewAmount", - modifier = Modifier - .fillMaxWidth() - .testTag("ReviewAmount") - ) + if (preparingRequest != null) { + Caption13Up( + text = stringResource(R.string.wallet__payment_request_requested_amount), + color = Colors.White64 + ) + VerticalSpacer(8.dp) + } + if (preparingRequest != null && preparingRequest.amount.asset != PaykitAsset.BTC) { + PaykitAmountDisplay(preparingRequest.amount) + } else { + BalanceHeaderView( + sats = (preparingRequest?.amount?.atomic ?: uiState.amount).toLong(), + useSwipeToHide = false, + onClick = { onEvent(SendEvent.BackToAmount) }.takeUnless { isPreparing }, + testTag = "ReviewAmount", + modifier = Modifier + .fillMaxWidth() + .testTag("ReviewAmount") + ) + } BoxWithConstraints( modifier = Modifier @@ -431,20 +436,13 @@ private fun ContentRunning( PaymentRequestSummary( profile = if (isPreparing) preparingContact else uiState.contactPaymentProfile, note = if (isPreparing) preparingRequest.note else uiState.oneOffPaymentRequestNote, - iconColor = accentColor, + accentColor = accentColor, ) VerticalSpacer(16.dp) } - Image( - painter = painterResource(R.drawable.coin_stack_4), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxWidth(IMAGE_FILL_PERCENTAGE) - .weight(1f) - .align(Alignment.CenterHorizontally) - .padding(bottom = 16.dp) - .graphicsLayer { rotationZ = swipeProgress.floatValue * SWIPE_ROTATION_DEGREES } + PaymentReviewIllustration( + swipeProgress = { swipeProgress.floatValue }, + modifier = Modifier.weight(1f) ) } } @@ -589,10 +587,10 @@ private fun OnChainDetails( stringResource(R.string.wallet__savings__title) }, color = if (uiState.hardwareWalletId != null) Colors.Blue else Colors.Brand, - enabled = uiState.canSwitchFundingSource && interactionsEnabled, + enabled = uiState.canSwitchBitcoinFundingSource && interactionsEnabled, isLoading = uiState.isFundingSourceLoading, clickable = interactionsEnabled && !uiState.isRetainedHardwarePayment, - icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchBitcoinFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") ) @@ -737,9 +735,9 @@ private fun LightningDetails( NumberPadActionButton( text = stringResource(R.string.wallet__spending__title), color = Colors.Purple, - enabled = uiState.canSwitchFundingSource && interactionsEnabled, + enabled = uiState.canSwitchBitcoinFundingSource && interactionsEnabled, isLoading = uiState.isFundingSourceLoading, - icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchBitcoinFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") ) @@ -890,97 +888,6 @@ private fun ContactRecipient( } } -@Composable -private fun PaymentRequestSummary( - profile: PubkyProfile?, - note: String?, - iconColor: Color, - modifier: Modifier = Modifier, -) { - if (profile == null) return - val noteColor = if (note != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier.height(IntrinsicSize.Min) - ) { - SendCell( - caption = stringResource(R.string.wallet__send_from), - modifier = Modifier.weight(1f) - ) { - PaymentRequestSummaryValue( - text = profile.name, - icon = R.drawable.ic_user, - iconColor = iconColor, - testTag = "PaymentRequestFrom", - ) - } - SendCell( - caption = stringResource(R.string.wallet__payment_request_for), - modifier = Modifier.weight(1f) - ) { - PaymentRequestSummaryValue( - text = note ?: stringResource(R.string.wallet__payment_request_not_specified), - textColor = noteColor, - icon = R.drawable.ic_note, - iconColor = iconColor, - testTag = "PaymentRequestFor", - ) - } - } -} - -@Composable -private fun PaymentRequestSummaryValue( - text: String, - @DrawableRes icon: Int, - iconColor: Color, - testTag: String, - modifier: Modifier = Modifier, - textColor: Color = MaterialTheme.colorScheme.primary, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier - ) { - Icon( - painter = painterResource(icon), - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(16.dp) - ) - BodySSB( - text = text, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.testTag(testTag) - ) - } -} - -@Composable -private fun PaymentRequestInvoiceNote( - note: String, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier.fillMaxWidth()) { - Caption13Up(text = stringResource(R.string.wallet__activity_invoice_note), color = Colors.White64) - VerticalSpacer(8.dp) - ZigzagDivider() - Title( - text = note, - color = Colors.White, - modifier = Modifier - .fillMaxWidth() - .background(Colors.White10) - .padding(24.dp) - .testTag("PaymentRequestInvoiceNote") - ) - } -} - private val SendUiState.isOneOffPaymentRequest: Boolean get() = isPaymentRequest && !isSubscriptionPayment diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt index 1dc7352738..7c127898e0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.GradientCircularProgressIndicator @@ -35,7 +36,7 @@ import to.bitkit.ui.theme.Colors fun SendContactSelectScreen( viewModel: SendContactSelectViewModel, onBack: () -> Unit, - onOpenPayment: (String, String, PrivatePaykitPaymentContext?) -> Unit, + onOpenPayment: (String, String, PrivatePaykitPaymentContext?, List) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -43,7 +44,7 @@ fun SendContactSelectScreen( viewModel.effects.collect { when (it) { is SendContactSelectEffect.OpenPayment -> - onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext) + onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext, it.endpoints) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index 12a0cb0dbc..bc31cc868d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo @@ -75,6 +76,7 @@ class SendContactSelectViewModel @Inject constructor( result.paymentRequest, publicKey, result.privatePaymentContext, + result.endpoints, ) ) PublicPaykitPaymentResult.NoEndpoint -> @@ -120,5 +122,6 @@ sealed interface SendContactSelectEffect { val paymentRequest: String, val publicKey: String, val privatePaymentContext: PrivatePaykitPaymentContext?, + val endpoints: List = emptyList(), ) : SendContactSelectEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index afe13bbb41..96d6d8326b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -380,7 +380,7 @@ internal fun SendPendingContent( } @Composable -private fun HourglassAnimation(modifier: Modifier = Modifier) { +fun HourglassAnimation(modifier: Modifier = Modifier) { val infiniteTransition = rememberInfiniteTransition(label = "hourglass") val rotation by infiniteTransition.animateFloat( initialValue = -16f, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt new file mode 100644 index 0000000000..fc5f533ded --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt @@ -0,0 +1,144 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtTransferStatus +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.ui.scaffold.AppAlertDialog +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.AppViewModel +import to.bitkit.viewmodels.SendUiState +import kotlin.time.Duration.Companion.seconds + +@Composable +fun PaykitUsdtReview( + uiState: SendUiState, + app: AppViewModel, + onBack: () -> Unit, + onDone: () -> Unit, + viewModel: UsdtViewModel = hiltViewModel() +) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(state.busy, state.authenticationRequired) { + app.updatePaykitUsdtBusy(state.busy || state.authenticationRequired) + onDispose { app.updatePaykitUsdtBusy(false) } + } + var details by remember { mutableStateOf(false) } + val back = { + if (!state.busy && !state.authenticationRequired) { + if (state.submitted) onDone() else onBack() + } + } + DisposableEffect(viewModel) { + viewModel.sendPayment = app::sendPaykitUsdt + viewModel.isSendPresented = true + onDispose { + viewModel.sendPayment = null + viewModel.isSendPresented = false + } + } + LaunchedEffect(Unit) { + if (viewModel.state.value.quote == null) { + viewModel.quote(requireNotNull(uiState.usdtRecipient), uiState.paykitAmount, UsdtDestination.ARBITRUM) + } + } + LaunchedEffect(state.submitted, lifecycle) { + if (state.submitted) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + reconcileSubmittedPayment(viewModel, app) + } + } + } + BackHandler { if (state.authenticationRequired) viewModel.cancelAuthentication() else back() } + state.warning?.let { warning -> + AppAlertDialog( + title = stringResource(R.string.common__are_you_sure), + text = stringResource(warning.message), + confirmText = stringResource(R.string.wallet__send_yes), + dismissText = stringResource(R.string.common__cancel), + onConfirm = viewModel::acceptWarning, + onDismiss = viewModel::edit + ) + } + val transfer = wallet.transfers.firstOrNull { it.id == state.quote?.id } + if (details && transfer != null) { + UsdtActivityDetail(transfer, settings.hideBalance, wallet.address) { details = false } + } else { + Column( + Modifier.sheetHeight().background(Colors.Gray7) + ) { + if (state.authenticationRequired) { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + UsdtAuthentication( + settings.isBiometricEnabled, + viewModel::cancelAuthentication, + viewModel::authenticationVerified + ) + } + } else { + UsdtPaymentContent( + wallet, state, UsdtPage.AMOUNT, + uiState.usdtRecipient.orEmpty(), uiState.paykitAmount, UsdtDestination.ARBITRUM, + onRecipientChange = {}, + onAmountChange = {}, + onRecipientContinue = {}, + onPageChange = { back() }, + onBack = back, + onReview = { + viewModel.quote( + requireNotNull(uiState.usdtRecipient), + uiState.paykitAmount, + UsdtDestination.ARBITRUM + ) + }, + onConfirm = { + viewModel.confirm() + }, + onDone = onDone, + onDetails = { details = true }, + hideBalance = settings.hideBalance, + amountEditable = false, + isPaymentRequest = uiState.isPaymentRequest, + paymentRequestNote = uiState.paymentRequestNote, + contact = uiState.contactPaymentProfile, + ) + } + } + } +} + +private suspend fun reconcileSubmittedPayment(viewModel: UsdtViewModel, app: AppViewModel) { + viewModel.waitForTransfer() + while (viewModel.wallet.value.transfers.any { + it.id == viewModel.state.value.quote?.id && it.status == UsdtTransferStatus.PENDING + } + ) { + delay(10.seconds) + viewModel.refresh() + } + app.reconcilePaykitUsdt() +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt new file mode 100644 index 0000000000..4ff616ed18 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt @@ -0,0 +1,476 @@ +package to.bitkit.ui.screens.wallets.usdt + +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.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtOrchestraTransfer +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.usdtFormatAmount +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ext.UiDateStyle +import to.bitkit.ext.uiDateStyleFor +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.LargeRow +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.screens.wallets.activity.components.ActivityRowSurface +import to.bitkit.ui.screens.wallets.activity.components.AmountViewContent +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.settingsViewModel +import to.bitkit.ui.shared.UiConstants +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.uiDateText + +@Composable +internal fun UsdtActivityRow(transfer: UsdtTransfer, hideBalance: Boolean, onClick: () -> Unit) { + val contactName = paykitContactName(transfer.id) + ActivityRowSurface(onClick = onClick, modifier = Modifier.testTag("UsdtActivity-" + transfer.id)) { + UsdtActivityIcon(transfer) + HorizontalSpacer(16.dp) + Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { + BodyMSB(contactName ?: stringResource(transfer.titleResource), maxLines = 1) + CaptionB( + if (transfer.status == UsdtTransferStatus.BRIDGING) { + stringResource(R.string.usdt__to_network).replace("{network}", transfer.destination.label) + } else { + uiDateText(transfer.timestamp, uiDateStyleFor(transfer.timestamp)) + }, + color = Colors.White64, + maxLines = 1 + ) + } + HorizontalSpacer(16.dp) + AmountViewContent( + title = usdtOverviewAmount(transfer.activityAmount), + titlePrefix = if (transfer.isIncoming) "+" else "−", + subtitle = "$ " + usdtOverviewAmount(transfer.activityAmount), + hideBalance = hideBalance + ) + } +} + +@Composable +private fun UsdtActivityIcon(transfer: UsdtTransfer, size: Dp = 40.dp) { + CircularIcon( + icon = painterResource(transfer.iconResource), + iconColor = transfer.activityIconColor, + backgroundColor = transfer.activityIconColor.copy(alpha = 0.16f), + size = size + ) +} + +@Composable +internal fun UsdtActivityDetail( + transfer: UsdtTransfer, + hideBalance: Boolean, + walletAddress: String, + onBack: () -> Unit, +) { + val contactName = paykitContactName(transfer.id) + val app = appViewModel + val canOpenDeposits = transfer.isIncoming && Env.usdtDepositsUrl != null + var showDetails by rememberSaveable(transfer.id) { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxSize()) { + AppTopBar( + titleText = stringResource( + transfer.detailTitleResource + ), + onBackClick = onBack, + titleStyle = UsdtDetailStyles.Title, + actions = { DrawerNavIcon() } + ) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + UsdtActivityAmountHeader( + amount = transfer.activityAmount, + isIncoming = transfer.isIncoming, + hideBalance = hideBalance, + modifier = Modifier.padding(top = 16.dp, bottom = 16.dp) + ) + if (showDetails) { + UsdtTechnicalDetails(transfer, hideBalance, walletAddress) + if (canOpenDeposits) UsdtTransactionDetails(transfer) + } else { + UsdtActivitySummary(transfer, hideBalance) + if (contactName != null) { + UsdtDetailField( + caption = stringResource(R.string.wallet__payment_request_contact) + ) { BodySSB(contactName, style = UsdtDetailStyles.Body) } + } + } + PrimaryButton( + text = stringResource( + if (showDetails) R.string.common__hide_details else R.string.wallet__send_details + ), + onClick = { showDetails = !showDetails }, + size = ButtonSize.Small, + textStyle = UsdtDetailStyles.SmallButton, + icon = { + Icon( + painterResource(if (showDetails) R.drawable.ic_eye_slash else R.drawable.ic_git_branch), + null, + tint = Colors.Usdt, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("UsdtActivityDetails") + ) + if (transfer.destination != UsdtDestination.ARBITRUM && ( + showDetails || transfer.status in setOf( + UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, UsdtTransferStatus.BRIDGE_FAILED, + UsdtTransferStatus.FAILED, UsdtTransferStatus.BRIDGE_REFUNDED, + ) + ) + ) { + UsdtSupportActions(transfer.supportDetails) + } + } + if (showDetails && (transfer.txHash != null || canOpenDeposits)) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + if (canOpenDeposits) { + PrimaryButton( + text = stringResource(R.string.usdt__deposit_history), + textStyle = UsdtDetailStyles.Button, + onClick = { app?.navigateToUsdtDeposits() }, + modifier = Modifier.testTag("UsdtDepositHistory") + ) + } else { + UsdtTransactionDetails(transfer) + } + } + } + } +} + +@Composable +internal fun UsdtActivityAmountHeader( + amount: ULong?, + isIncoming: Boolean, + hideBalance: Boolean, + modifier: Modifier = Modifier, +) { + val amountText = amount?.let { usdtOverviewAmount(it) } ?: "—" + Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier) { + Caption13Up( + text = "$ " + if (hideBalance) UiConstants.HIDE_BALANCE_SHORT else amountText, + color = Colors.White64, + style = UsdtDetailStyles.Caption, + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.height(36.dp)) { + LargeRow( + prefix = if (isIncoming) "+" else "-", + text = amountText, + symbol = "", + showSymbol = false, + hideBalance = hideBalance, + prefixSpacing = 6.dp, + modifier = Modifier.weight(1f).wrapContentHeight(unbounded = true) + ) + CircularIcon( + icon = painterResource(if (isIncoming) R.drawable.ic_received else R.drawable.ic_sent), + iconColor = Colors.Usdt, + backgroundColor = Colors.Usdt.copy(alpha = 0.16f), + size = 48.dp, + modifier = Modifier.requiredSize(48.dp) + ) + } + } +} + +internal val UsdtTransfer.detailTitleResource: Int get() = when { + isIncoming && status == UsdtTransferStatus.PENDING -> R.string.usdt__receiving_title + isIncoming -> R.string.usdt__payment_received + status == UsdtTransferStatus.BRIDGING -> R.string.usdt__delivering_title + else -> R.string.usdt__sent_title +} + +@Composable +private fun UsdtTechnicalDetails(transfer: UsdtTransfer, hideBalance: Boolean, walletAddress: String) { + transfer.txHash?.let { hash -> + UsdtDetailField(caption = stringResource(R.string.wallet__activity_tx_id)) { + SelectionContainer { BodySSB(hash, style = UsdtDetailStyles.Body) } + } + } + val sender = transfer.sender ?: walletAddress.takeIf { !transfer.isIncoming && it.isNotEmpty() } + if (sender != null) { + UsdtDetailField(caption = stringResource(R.string.usdt__from_address)) { + SelectionContainer { BodySSB(sender, style = UsdtDetailStyles.Body) } + } + } + UsdtDetailField(caption = stringResource(R.string.usdt__to_address)) { + SelectionContainer { BodySSB(transfer.recipient, style = UsdtDetailStyles.Body) } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (!transfer.isIncoming) { + UsdtDetailCell( + stringResource(R.string.usdt__sender_network), + UsdtDestination.ARBITRUM.label, + R.drawable.usdt_network, + modifier = Modifier.weight(1f) + ) + } + UsdtDetailCell( + stringResource(R.string.usdt__destination), + transfer.destination.label, + R.drawable.usdt_network, + modifier = Modifier.weight(1f) + ) + } + transfer.orchestra?.let { UsdtOrchestraDetails(it, hideBalance) } + if (transfer.destination != UsdtDestination.ARBITRUM && transfer.status !in setOf( + UsdtTransferStatus.BRIDGE_REFUNDED, UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED, + ) + ) { + UsdtDetailField( + caption = stringResource( + if (transfer.status == UsdtTransferStatus.CONFIRMED) { + R.string.usdt__recipient_gets + } else { + R.string.usdt__expected_amount + } + ) + ) { + BodySSB( + if (hideBalance) { + UiConstants.HIDE_BALANCE_SHORT + } else { + usdtOverviewAmount(transfer.receivedAmount) + " USDT" + } + ) + } + } +} + +@Composable +private fun UsdtActivitySummary(transfer: UsdtTransfer, hideBalance: Boolean) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + UsdtDetailCell( + stringResource(R.string.wallet__activity_status), + stringResource(transfer.status.labelResource), + when (transfer.status) { + UsdtTransferStatus.CONFIRMED -> R.drawable.ic_check_circle + UsdtTransferStatus.BRIDGING -> R.drawable.ic_hourglass_simple + else -> transfer.iconResource + }, + textStyle = UsdtDetailStyles.Body, + valueColor = transfer.statusColor, + iconColor = transfer.statusColor, + modifier = Modifier.weight(1f).testTag("UsdtActivityStatus") + ) + UsdtDetailCell( + stringResource(R.string.usdt__receiving_network), + transfer.destination.label, + R.drawable.usdt_network, + modifier = Modifier.weight(1f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + UsdtDetailCell( + stringResource(R.string.wallet__activity_date), + uiDateText(transfer.timestamp, UiDateStyle.DATE), + R.drawable.ic_calendar, + textStyle = UsdtDetailStyles.Body, + modifier = Modifier.weight(1f) + ) + UsdtDetailCell( + stringResource(R.string.wallet__activity_time), + uiDateText(transfer.timestamp, UiDateStyle.TIME), + R.drawable.ic_clock, + modifier = Modifier.weight(1f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + UsdtDetailCell( + stringResource(R.string.wallet__activity_payment), + if (hideBalance) UiConstants.HIDE_BALANCE_SHORT else usdtOverviewAmount(transfer.amount), + R.drawable.ic_user, + modifier = Modifier.weight(1f) + ) + UsdtDetailCell( + stringResource(R.string.wallet__activity_fee), + if (hideBalance) UiConstants.HIDE_BALANCE_SHORT else transfer.fee?.let { usdtOverviewAmount(it) } ?: "—", + R.drawable.ic_speed_normal, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +internal fun UsdtDetailCell( + title: String, + value: String, + icon: Int, + modifier: Modifier = Modifier, + valueColor: Color = Colors.White, + iconColor: Color = Colors.Usdt, + minValueHeight: Dp = 0.dp, + textStyle: TextStyle = UsdtDetailStyles.Value, +) { + UsdtDetailField(caption = title, modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.heightIn(min = minValueHeight).wrapContentHeight(Alignment.Top), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(16.dp)) { + Icon( + painterResource(icon), + null, + tint = iconColor, + modifier = if (icon == R.drawable.ic_clock) Modifier.size(13.dp, 14.dp) else Modifier.size(16.dp) + ) + } + BodySSB(value, color = valueColor, style = textStyle) + } + } +} + +private val UsdtTransfer.titleResource: Int get() = when (status) { + UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED, UsdtTransferStatus.PENDING, + UsdtTransferStatus.BRIDGE_REFUNDED, UsdtTransferStatus.BRIDGING, + UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, UsdtTransferStatus.BRIDGE_FAILED -> + status.labelResource + else -> if (isIncoming) R.string.usdt__received else R.string.usdt__sent +} + +private val UsdtTransfer.iconResource: Int get() = when (status) { + UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED, UsdtTransferStatus.BRIDGE_FAILED -> R.drawable.ic_x_circle + UsdtTransferStatus.PENDING, UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION -> R.drawable.ic_hourglass_simple + UsdtTransferStatus.BRIDGING -> R.drawable.ic_transfer + UsdtTransferStatus.BRIDGE_REFUNDED -> R.drawable.ic_received + UsdtTransferStatus.CONFIRMED -> if (isIncoming) R.drawable.ic_received else R.drawable.ic_sent +} + +private val UsdtTransfer.activityIconColor get() = when (status) { + UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED, UsdtTransferStatus.BRIDGE_FAILED -> Colors.Red + UsdtTransferStatus.PENDING, UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION -> Colors.Yellow + else -> Colors.Usdt +} + +private val UsdtTransfer.statusColor get() = when (status) { + UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED -> Colors.Red + UsdtTransferStatus.PENDING, UsdtTransferStatus.BRIDGING, + UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, UsdtTransferStatus.BRIDGE_FAILED -> Colors.Yellow + else -> Colors.Usdt +} + +private val UsdtTransferStatus.labelResource: Int get() = when (this) { + UsdtTransferStatus.PENDING -> R.string.usdt__pending + UsdtTransferStatus.CONFIRMED -> R.string.usdt__confirmed + UsdtTransferStatus.FAILED -> R.string.usdt__failed + UsdtTransferStatus.BRIDGING -> R.string.usdt__delivering + UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, UsdtTransferStatus.BRIDGE_FAILED -> R.string.usdt__bridge_attention + UsdtTransferStatus.BRIDGE_REFUNDED -> R.string.usdt__deposit_refunded + UsdtTransferStatus.REPLACED -> R.string.usdt__replaced +} + +internal val UsdtTransfer.activityAmount: ULong get() { + if (isIncoming) return amount + if (status == UsdtTransferStatus.FAILED || status == UsdtTransferStatus.REPLACED) return fee ?: 0u + val fee = fee ?: return amount + return if (ULong.MAX_VALUE - amount < fee) ULong.MAX_VALUE else amount + fee +} + +@Composable +private fun paykitContactName(transferId: String): String? { + val settings = settingsViewModel ?: return null + val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() + if (!isPaykitEnabled) return null + val app = appViewModel ?: return null + val attempts by app.paykitUsdtPayments.attempts.collectAsStateWithLifecycle() + val receipts by app.paykitUsdtPayments.receipts.collectAsStateWithLifecycle() + val contacts by app.pubkyContacts.collectAsStateWithLifecycle() + val key = attempts.firstOrNull { it.quoteId == transferId }?.contact + ?: receipts.firstOrNull { it.transferId == transferId }?.requestId?.counterparty ?: return null + return contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, key) }?.name + ?: PubkyPublicKeyFormat.display(key) +} + +@Composable +private fun UsdtOrchestraDetails(bridge: UsdtOrchestraTransfer, hideBalance: Boolean) { + UsdtDetailCell(stringResource(R.string.usdt__bridge_provider), "Orchestra", R.drawable.ic_transfer) + SelectionContainer { + UsdtDetailCell(stringResource(R.string.usdt__bridge_reference), bridge.quoteId, R.drawable.ic_note) + } + bridge.destinationTx?.let { hash -> + UsdtDetailField( + caption = stringResource(R.string.usdt__destination_tx) + ) { SelectionContainer { BodyS(hash) } } + } + bridge.refundAmount?.let { amount -> + UsdtDetailField(caption = stringResource(R.string.usdt__deposit_refunded)) { + BodySSB(if (hideBalance) UiConstants.HIDE_BALANCE_SHORT else usdtFormatAmount(amount) + " USDT") + } + } + bridge.refundTx?.let { hash -> + UsdtDetailField(caption = stringResource(R.string.usdt__refund_tx)) { SelectionContainer { BodyS(hash) } } + } +} + +@Composable +private fun UsdtTransactionDetails(transfer: UsdtTransfer) { + val uri = LocalUriHandler.current + transfer.txHash?.let { txHash -> + if (transfer.destination != UsdtDestination.ARBITRUM && transfer.orchestra == null) { + SecondaryButton(text = stringResource(R.string.usdt__track_bridge), onClick = { + uri.openUri("https://layerzeroscan.com/tx/" + txHash) + }) + } + PrimaryButton( + text = stringResource(R.string.wallet__activity_explorer), + textStyle = UsdtDetailStyles.Button, + onClick = { uri.openUri("https://arbiscan.io/tx/" + txHash) }, + modifier = Modifier.testTag("UsdtActivityExplorer") + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt new file mode 100644 index 0000000000..e6a2a62ffb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt @@ -0,0 +1,308 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.UsdtDepositDetail +import com.synonym.bitkitcore.UsdtDepositNetwork +import com.synonym.bitkitcore.usdtFormatAmount +import kotlinx.collections.immutable.ImmutableList +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SendCell +import to.bitkit.ui.components.TertiaryButton +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing +import to.bitkit.ui.screens.wallets.receive.ReceiveTab +import to.bitkit.ui.theme.Colors +import java.text.DecimalFormatSymbols +import java.text.NumberFormat +import java.util.Locale + +@Suppress("CyclomaticComplexMethod") +@Composable +internal fun ColumnScope.UsdtDepositReceiveScreen( + viewModel: UsdtViewModel, + tabs: ImmutableList, + onSelectTab: (ReceiveTab) -> Unit, + onBack: () -> Unit, + onBlockingChange: (Boolean) -> Unit, + onContacts: (() -> Unit)? = null, + deposits: UsdtDepositViewModel = hiltViewModel(), +) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val walletUi by viewModel.state.collectAsStateWithLifecycle() + val state by deposits.state.collectAsStateWithLifecycle() + var page by remember { mutableStateOf(ReceivePage.ADDRESS) } + var amount by remember { mutableStateOf("") } + var draftAmount by remember { mutableStateOf("") } + val app = appViewModel + val back = { + if (!state.busy) { + when (page) { + ReceivePage.FEES -> { + deposits.selectNetwork(state.network) + page = ReceivePage.AMOUNT + } + ReceivePage.AMOUNT -> { + draftAmount = amount + if (state.network != null && state.address == null) deposits.selectNetwork(null) + page = ReceivePage.ADDRESS + } + ReceivePage.NETWORKS -> page = ReceivePage.ADDRESS + ReceivePage.ADDRESS -> onBack() + } + } + } + LaunchedEffect(state.busy) { onBlockingChange(state.busy) } + DisposableEffect(Unit) { onDispose { onBlockingChange(false) } } + LaunchedEffect(state.address) { if (state.address != null) page = ReceivePage.FEES } + UsdtRefreshEffect(viewModel, receiving = true, onReceived = { !state.busy }) + BackHandler(onBack = back) + SheetTopBar( + titleText = stringResource( + when (page) { + ReceivePage.ADDRESS -> R.string.usdt__receive_title + ReceivePage.NETWORKS -> R.string.usdt__receive_network_title + ReceivePage.AMOUNT -> R.string.usdt__receive_amount_title + ReceivePage.FEES -> R.string.usdt__receive_fees_title + } + ), + onBack = back.takeIf { page != ReceivePage.ADDRESS }, + action = if (page == ReceivePage.ADDRESS && onContacts != null) { + { + IconButton(onClick = onContacts) { + Icon( + painterResource(R.drawable.ic_users), + stringResource(R.string.wallet__payment_request_choose_recipient) + ) + } + } + } else { + null + }, + ) + VerticalSpacer( + if (page == ReceivePage.NETWORKS || page == ReceivePage.AMOUNT) 8.dp else 16.dp + ) + if (page == ReceivePage.ADDRESS) { + CustomTabRowWithSpacing( + tabs = tabs, + currentTabIndex = tabs.indexOf(ReceiveTab.USDT), + onTabChange = onSelectTab + ) + VerticalSpacer(16.dp) + } + walletUi.error?.let { BodyS(stringResource(it), color = Colors.Brand) } + Content( + page = page, + onPage = { page = it }, + wallet = wallet, + receiveError = walletUi.refreshError, + state = state, + amount = if (page == ReceivePage.AMOUNT) draftAmount else amount, + onNetwork = { + if (state.network != it) { + amount = "" + draftAmount = "" + deposits.selectNetwork(it) + } + page = if (it == null || state.address != null && state.network == it) { + ReceivePage.ADDRESS + } else { + ReceivePage.AMOUNT + } + }, + onAmount = { + if (draftAmount != it) deposits.clearAmountError() + draftAmount = it + }, + onCreate = { + if (state.network == null) { + amount = draftAmount + page = ReceivePage.ADDRESS + } else { + deposits.prepare(draftAmount) + } + }, + onHistory = { app?.navigateToUsdtDeposits() }, + onRefresh = { + draftAmount = if (state.network == null) amount else draftAmount + deposits.selectNetwork(state.network) + page = ReceivePage.AMOUNT + }, + ) +} + +@Composable +private fun ColumnScope.Content( + page: ReceivePage, + onPage: (ReceivePage) -> Unit, + wallet: UsdtWalletState, + receiveError: Int?, + state: DepositUiState, + amount: String, + onNetwork: (UsdtDepositNetwork?) -> Unit, + onAmount: (String) -> Unit, + onCreate: () -> Unit, + onHistory: () -> Unit, + onRefresh: () -> Unit, +) { + when (page) { + ReceivePage.NETWORKS -> NetworkSelector(state, onNetwork, onHistory) + ReceivePage.AMOUNT -> UsdtReceiveAmountContent( + amount, + state.network == null, + state.busy, + state.error, + state.amountLimits, + onAmount, + onCreate + ) + ReceivePage.FEES -> state.address?.let { address -> + UsdtAmountHeader(usdtFormatAmount(address.amount), displayAmount = usdtOverviewAmount(address.amount)) + VerticalSpacer(24.dp) + BodyM(stringResource(R.string.usdt__deposit_fee_note), color = Colors.White64) + VerticalSpacer(24.dp) + EstimateRow( + stringResource(R.string.usdt__receive_fee), + if (address.amount >= address.estimatedReceived) { + usdtOverviewAmount(address.amount - address.estimatedReceived) + } else { + "—" + } + ) + EstimateRow(stringResource(R.string.usdt__receive_estimate), usdtOverviewAmount(address.estimatedReceived)) + FillHeight() + PrimaryButton(stringResource(R.string.common__continue), onClick = { + onPage(ReceivePage.ADDRESS) + }, modifier = Modifier.testTag("UsdtReceiveFeesContinue")) + } + ReceivePage.ADDRESS -> { + val atomic = runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount).atomic }.getOrNull() + val uri = if (atomic == null) wallet.receiveUri else wallet.receiveUri + "&uint256=$atomic" + UsdtReceiveContent( + address = state.address?.address ?: wallet.address, + uri = state.address?.uri ?: uri, + error = receiveError, + network = state.network?.label ?: "Arbitrum One", + onEdit = onRefresh, + onNetwork = { onPage(ReceivePage.NETWORKS) }, + ) + } + } +} + +private enum class ReceivePage { ADDRESS, NETWORKS, AMOUNT, FEES } + +@Composable +private fun ColumnScope.NetworkSelector( + state: DepositUiState, + onNetwork: (UsdtDepositNetwork?) -> Unit, + onHistory: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()) + ) { + UsdtNetworkRow("Arbitrum One", state.network == null, onClick = { onNetwork(null) }) + state.networks.forEach { item -> + UsdtNetworkRow(item.label, state.network == item, onClick = { onNetwork(item) }) + } + BodyS( + stringResource(R.string.usdt__receive_network_note), + color = Colors.White64, + letterSpacing = 0.sp, + modifier = Modifier.padding(vertical = 16.dp) + ) + UsdtCoinIllustration(modifier = Modifier.align(Alignment.CenterHorizontally)) + if (Env.usdtDepositsUrl != null) { + TertiaryButton( + stringResource(R.string.usdt__deposit_history), + onClick = onHistory, + modifier = Modifier.testTag("UsdtDepositHistory") + ) + } + state.error?.let { BodyS(stringResource(it), color = Colors.White64) } + } +} + +@Composable +private fun EstimateRow(title: String, value: String) { + SendCell(caption = title, dividerSpacing = 24.dp) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + BodyMSB("± $", color = Colors.White64) + BodyMSB(value) + } + } + VerticalSpacer(24.dp) +} + +internal val UsdtDepositNetwork.label: String + get() = when (this) { + UsdtDepositNetwork.ETHEREUM -> "Ethereum" + UsdtDepositNetwork.SOLANA -> "Solana" + UsdtDepositNetwork.POLYGON -> "Polygon" + UsdtDepositNetwork.BSC -> "BNB Smart Chain" + UsdtDepositNetwork.BASE -> "Base" + UsdtDepositNetwork.TRON -> "Tron" + } +internal fun String.depositNetwork(): UsdtDepositNetwork? = + UsdtDepositNetwork.entries.firstOrNull { it.name.equals(this, ignoreCase = true) } +internal fun String.usdCents(locale: Locale = Locale.getDefault()): String = toULongOrNull()?.let { + val whole = NumberFormat.getIntegerInstance(locale).format((it / 100u).toLong()) + "$$whole${DecimalFormatSymbols.getInstance(locale).decimalSeparator}${(it % 100u).toString().padStart(2, '0')}" +} ?: "—" +internal fun depositStatus(status: String, code: String?): Int = when (status) { + "completed" -> R.string.usdt__confirmed + "refunded" -> R.string.usdt__deposit_refunded + "refunding", "refund_requested" -> if (code == null || code == "standing_refund_pending") { + R.string.usdt__deposit_refunding + } else { + R.string.usdt__deposit_needs_attention + } + "pending", "processing", "funded", "confirming", "swapping", "bridging", "delivering" -> { + if (code == null) R.string.usdt__pending else R.string.usdt__deposit_needs_attention + } + else -> R.string.usdt__deposit_needs_attention +} + +internal val UsdtDepositDetail.statusCode: String? + get() = if (order != null) order?.code else deposit.code + +internal fun UsdtDepositDetail.statusResource(): Int = depositStatus(order?.status ?: deposit.status, statusCode) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositViewModel.kt new file mode 100644 index 0000000000..bd3e4184f3 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositViewModel.kt @@ -0,0 +1,194 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDepositAddress +import com.synonym.bitkitcore.UsdtDepositDetail +import com.synonym.bitkitcore.UsdtDepositNetwork +import com.synonym.bitkitcore.UsdtException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.repositories.UsdtDepositRepo +import javax.inject.Inject + +@HiltViewModel +class UsdtDepositViewModel @Inject constructor(private val repo: UsdtDepositRepo) : ViewModel() { + private val _state = MutableStateFlow(DepositUiState()) + val state = _state.asStateFlow() + private val pageOffsets = mutableMapOf() + + init { + viewModelScope.launch { + repo.depositNetworks().onSuccess { networks -> _state.update { it.copy(networks = networks) } } + .onFailure { _state.update { it.copy(error = R.string.usdt__deposit_unavailable) } } + } + } + + fun selectNetwork(network: UsdtDepositNetwork?) { + if (!_state.value.busy) { + _state.update { it.copy(network = network, address = null, error = null, amountLimits = null) } + } + } + + fun clearAmountError() { + _state.update { it.copy(error = null, amountLimits = null) } + } + + fun prepare(amount: String) = viewModelScope.launch { + val network = _state.value.network ?: return@launch + operation { + repo.prepareDeposit(network, amount).onSuccess { address -> _state.update { it.copy(address = address) } } + } + } + + fun history(offset: UInt = 0u) = viewModelScope.launch { + operation { + repo.depositHistory(offset).onSuccess { page -> + if (offset == 0u) pageOffsets.clear() + page.deposits.forEach { pageOffsets[it.id] = offset } + _state.update { + it.copy( + history = true, + detail = null, + deposits = ( + if (offset == 0u) { + page.deposits + } else { + val updatedIds = page.deposits.map { deposit -> deposit.id }.toSet() + it.deposits.filterNot { deposit -> deposit.id in updatedIds } + page.deposits + } + ).toImmutableList(), + offset = offset, + nextOffset = page.nextOffset, + message = null + ) + } + } + } + } + + fun more() { + _state.value.nextOffset?.let { history(it) } + } + + fun refresh() = viewModelScope.launch { + val id = _state.value.detail?.deposit?.id + if (id == null) history().join() else detail(id) + } + + suspend fun detail(id: String) { + operation { + repo.depositDetail(id, pageOffsets[id] ?: _state.value.offset).onSuccess(::updateDetail) + } + } + + suspend fun refreshDetail(id: String) { + val current = _state.value + if (current.busy || current.detail?.deposit?.id != id) return + val result = repo.depositDetail(id, current.offset) + if (_state.value !== current) return + result.onSuccess(::updateDetail).onFailure { error -> + _state.update { it.copy(error = error.messageResource()) } + } + } + + private fun updateDetail(detail: UsdtDepositDetail) { + _state.update { + it.copy( + detail = detail, + deposits = it.deposits.map { deposit -> + if (deposit.id == detail.deposit.id) { + detail.deposit.copy(status = detail.providerStatus, code = detail.statusCode) + } else { + deposit + } + }.toImmutableList(), + offset = pageOffsets[detail.deposit.id] ?: it.offset, + message = if (detail.providerStatus in listOf("completed", "refunded")) null else it.message, + error = null, + ) + } + } + + fun back(): Boolean { + if (_state.value.busy) return true + return when { + _state.value.detail != null -> { + _state.update { it.copy(detail = null, message = null) } + true + } + _state.value.history -> { + _state.update { it.copy(history = false, error = null, amountLimits = null) } + true + } + else -> false + } + } + + fun refund(id: String, offset: UInt, address: String, network: UsdtDepositNetwork) = viewModelScope.launch { + val detail = _state.value.detail ?: return@launch + if (detail.deposit.id != id || !detail.canRefund || detail.deposit.network.depositNetwork() != network) { + return@launch + } + operation { + repo.refundDeposit(id, offset, address, network).onSuccess { + _state.update { + it.copy( + detail = it.detail?.let { detail -> + detail.copy( + deposit = detail.deposit.copy(status = "refund_requested", code = null), + order = detail.order?.copy(status = "refund_requested", code = null), + ) + }, + deposits = it.deposits.map { deposit -> + if (deposit.id == id) deposit.copy(status = "refund_requested", code = null) else deposit + }.toImmutableList(), + message = R.string.usdt__deposit_refund_requested, + ) + } + } + } + } + + private suspend fun operation(block: suspend () -> Result<*>) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, error = null, amountLimits = null) } + try { + block().onFailure { error -> + _state.update { + it.copy( + error = error.messageResource(), + amountLimits = error as? UsdtException.DepositAmountOutOfRange, + ) + } + } + } finally { + _state.update { it.copy(busy = false) } + } + } +} + +@Stable +data class DepositUiState( + val networks: ImmutableList = persistentListOf(), + val network: UsdtDepositNetwork? = null, + val address: UsdtDepositAddress? = null, + val history: Boolean = false, + val deposits: ImmutableList = persistentListOf(), + val offset: UInt = 0u, + val nextOffset: UInt? = null, + val detail: UsdtDepositDetail? = null, + val busy: Boolean = false, + val error: Int? = null, + val amountLimits: UsdtException.DepositAmountOutOfRange? = null, + val message: Int? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt new file mode 100644 index 0000000000..ef261d12cc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt @@ -0,0 +1,535 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDepositDetail +import com.synonym.bitkitcore.UsdtDepositNetwork +import com.synonym.bitkitcore.UsdtDestination +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.usdtExactAmount +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.AppAlertDialog +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.screens.wallets.activity.components.ActivityRowSurface +import to.bitkit.ui.screens.wallets.activity.components.AmountViewContent +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.shared.UiConstants +import to.bitkit.ui.theme.Colors +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun UsdtDepositsScreen( + onBack: () -> Unit, + deposits: UsdtDepositViewModel = hiltViewModel(), + wallet: UsdtViewModel = hiltViewModel(), +) { + val state by deposits.state.collectAsStateWithLifecycle() + val settings by wallet.settings.collectAsStateWithLifecycle() + val walletUi by wallet.state.collectAsStateWithLifecycle() + var address by rememberSaveable(state.detail?.deposit?.id) { mutableStateOf("") } + var approval by remember { mutableStateOf(null) } + var confirmation by remember { mutableStateOf(false) } + var authentication by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val lifecycle = LocalLifecycleOwner.current.lifecycle + val back = { + if (authentication) { + authentication = false + approval = null + } else if (!state.busy && approval == null) { + if (state.detail != null) deposits.back() else onBack() + } + } + val refund = { + approval?.let { deposits.refund(it.id, it.offset, it.address, it.network) } + approval = null + } + LaunchedEffect(deposits) { deposits.history() } + DepositPolling(state.detail, lifecycle, approval != null, deposits::refreshDetail) + BackHandler(onBack = back) + if (authentication) { + UsdtAuthentication( + useBiometrics = settings.isBiometricEnabled, + onCancel = { + authentication = false + approval = null + }, + onVerified = { + authentication = false + refund() + }, + ) + } else { + UsdtDepositsContent( + state = state.copy(busy = state.busy || approval != null, error = state.error ?: walletUi.error), + address = address, + hideBalance = settings.hideBalance, + onBack = back, + onAddress = { address = it }, + onDetail = { scope.launch { deposits.detail(it) } }, + onHistory = { deposits.back() }, + onMore = deposits::more, + onRefresh = { deposits.refresh() }, + onRefund = { + refundApproval(state, address)?.let { + approval = it + confirmation = true + } + }, + ) + } + if (confirmation) { + approval?.let { approved -> + UsdtRefundDialog( + amount = approved.amount, + hideBalance = settings.hideBalance, + network = approved.network, + address = approved.address, + onCancel = { + confirmation = false + approval = null + }, + onConfirm = { + confirmation = false + scope.launch { + try { + authorizeRefund( + wallet = wallet, + isCurrent = { approval == approved }, + onAuthenticate = { authentication = true }, + onRefund = refund, + ) + } finally { + if (!authentication && approval == approved) approval = null + } + } + }, + ) + } + } +} + +private suspend fun authorizeRefund( + wallet: UsdtViewModel, + isCurrent: () -> Boolean, + onAuthenticate: () -> Unit, + onRefund: () -> Unit, +) { + wallet.readPaymentSettings { + if (!isCurrent()) return@readPaymentSettings + if (it.isPinEnabled && it.isPinForPaymentsEnabled) onAuthenticate() else onRefund() + } +} + +@Composable +private fun DepositPolling( + detail: UsdtDepositDetail?, + lifecycle: Lifecycle, + paused: Boolean, + refresh: suspend (String) -> Unit +) { + val id = detail?.takeIf { it.providerStatus !in listOf("completed", "refunded") }?.deposit?.id + LaunchedEffect(id, lifecycle, paused) { + if (id == null || paused) return@LaunchedEffect + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + delay(10.seconds) + refresh(id) + } + } + } +} + +private fun refundApproval(state: DepositUiState, address: String): RefundApproval? { + val detail = state.detail ?: return null + if (state.busy || !detail.canRefund) return null + val network = detail.deposit.network.depositNetwork() ?: return null + return RefundApproval(detail.deposit.id, state.offset, address.trim(), network, detail.deposit.amount) +} + +private data class RefundApproval( + val id: String, + val offset: UInt, + val address: String, + val network: UsdtDepositNetwork, + val amount: ULong?, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UsdtDepositsContent( + state: DepositUiState, + address: String, + hideBalance: Boolean, + onBack: () -> Unit, + onAddress: (String) -> Unit, + onDetail: (String) -> Unit, + onHistory: () -> Unit, + onMore: () -> Unit, + onRefresh: () -> Unit, + onRefund: () -> Unit, +) { + val detail = state.detail + var showDetails by rememberSaveable(detail?.deposit?.id) { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxSize().imePadding()) { + AppTopBar( + titleText = stringResource( + if (detail == null) R.string.usdt__deposit_history else R.string.usdt__receiving_title + ), + onBackClick = onBack, + titleStyle = UsdtDetailStyles.Title, + actions = { DrawerNavIcon() }, + ) + PullToRefreshBox( + isRefreshing = state.busy, + onRefresh = { if (!state.busy) onRefresh() }, + modifier = Modifier.weight(1f) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp) + ) { + if (detail == null) { + UsdtDepositHistory(state, hideBalance, onDetail, onMore) + } else { + if (detail.deposit.asset == "USDT") { + UsdtActivityAmountHeader( + amount = detail.deposit.amount, + isIncoming = true, + hideBalance = hideBalance, + modifier = Modifier.padding(bottom = 16.dp) + ) + } else { + BodyMSB(detail.deposit.asset) + } + if (showDetails) { + UsdtDepositTechnicalDetails(detail, address, state.busy, onAddress) + } else { + UsdtDepositSummary(detail) + } + UsdtDepositDetailActions( + showDetails = showDetails, + canRefund = detail.canRefund, + busy = state.busy, + address = address, + onRefund = onRefund, + onToggleDetails = { showDetails = !showDetails }, + ) + if (showDetails) { + if (detail.canRefund && detail.deposit.network.depositNetwork() == UsdtDepositNetwork.TRON) { + BodyS(stringResource(R.string.usdt__deposit_tron_refund_note), color = Colors.White64) + } + DepositSettlement(detail, hideBalance) + UsdtSupportActions(detail.supportDetails, enabled = !state.busy) + } + } + state.error?.let { BodyS(stringResource(it), color = Colors.Brand) } + state.message?.let { BodyS(stringResource(it), color = Colors.White64) } + } + } + if (detail != null) { + PrimaryButton( + text = stringResource(R.string.usdt__deposit_history), + textStyle = UsdtDetailStyles.Button, + onClick = onHistory, + enabled = !state.busy, + modifier = Modifier.padding(16.dp).testTag("UsdtDepositHistory") + ) + } + } +} + +@Composable +private fun UsdtDepositDetailActions( + showDetails: Boolean, + canRefund: Boolean, + busy: Boolean, + address: String, + onRefund: () -> Unit, + onToggleDetails: () -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (showDetails && canRefund) { + PrimaryButton( + text = stringResource(R.string.usdt__deposit_refund_action), + onClick = onRefund, + enabled = !busy && address.isNotBlank(), + isLoading = busy, + size = ButtonSize.Small, + textStyle = UsdtDetailStyles.SmallButton, + icon = { + Icon( + painterResource(R.drawable.ic_coins), + null, + tint = Colors.Usdt, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.weight(1f).testTag("UsdtDepositRefund") + ) + } + PrimaryButton( + text = stringResource( + if (showDetails) R.string.usdt__hide_details else R.string.wallet__send_details + ), + onClick = onToggleDetails, + size = ButtonSize.Small, + textStyle = UsdtDetailStyles.SmallButton, + icon = { + Icon( + painterResource( + if (showDetails) R.drawable.ic_eye_slash else R.drawable.ic_git_branch + ), + null, + tint = Colors.Usdt, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.weight(1f).testTag("UsdtDepositDetails") + ) + } +} + +@Composable +private fun ColumnScope.UsdtDepositHistory( + state: DepositUiState, + hideBalance: Boolean, + onDetail: (String) -> Unit, + onMore: () -> Unit, +) { + UsdtDetailField(caption = stringResource(R.string.usdt__deposit_provider_status)) { + BodySSB(stringResource(R.string.usdt__deposit_status_note), style = UsdtDetailStyles.Body) + } + if (state.deposits.isEmpty() && !state.busy) { + BodyS(stringResource(R.string.usdt__deposit_empty), color = Colors.White64) + } + state.deposits.forEach { deposit -> + UsdtDepositRow(deposit, hideBalance, onClick = { if (!state.busy) onDetail(deposit.id) }) + } + if (state.nextOffset != null) { + SecondaryButton(stringResource(R.string.usdt__deposit_more), onMore, enabled = !state.busy) + } +} + +@Composable +private fun UsdtDepositRow(deposit: UsdtDeposit, hideBalance: Boolean, onClick: () -> Unit) { + val status = depositStatus(deposit.status, deposit.code) + val confirmed = status == R.string.usdt__confirmed || status == R.string.usdt__deposit_refunded + val color = if (confirmed) Colors.Usdt else Colors.Yellow + ActivityRowSurface(onClick = onClick, modifier = Modifier.testTag("UsdtDepositRow-" + deposit.id)) { + CircularIcon( + icon = painterResource(if (confirmed) R.drawable.ic_check_circle else R.drawable.ic_hourglass_simple), + iconColor = color, + backgroundColor = color.copy(alpha = 0.16f), + size = 40.dp, + ) + HorizontalSpacer(16.dp) + Column(modifier = Modifier.weight(1f)) { + BodyMSB( + deposit.network.depositNetwork()?.label ?: deposit.network, + maxLines = 1, + style = UsdtDetailStyles.HistoryTitle + ) + CaptionB( + stringResource(status), + color = Colors.White64, + maxLines = 1, + style = UsdtDetailStyles.HistorySubtitle + ) + } + HorizontalSpacer(16.dp) + AmountViewContent( + title = if (deposit.asset == "USDT") { + deposit.amount?.let { usdtOverviewAmount(it) + " USDT" } ?: "—" + } else { + deposit.asset + }, + titlePrefix = "", + subtitle = if (deposit.asset == "USDT") { + deposit.amount?.let { "$" + usdtOverviewAmount(it) } ?: "—" + } else { + "—" + }, + hideBalance = hideBalance, + titleStyle = UsdtDetailStyles.HistoryTitle, + subtitleStyle = UsdtDetailStyles.HistorySubtitle, + verticalSpacing = 0.dp, + ) + } +} + +@Composable +private fun UsdtDepositSummary(detail: UsdtDepositDetail) { + val settled = detail.providerStatus in listOf("completed", "refunded") + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + UsdtDetailCell( + title = stringResource(R.string.wallet__activity_status), + value = stringResource(detail.statusResource()), + icon = if (settled) { + R.drawable.ic_check_circle + } else { + R.drawable.ic_hourglass_simple + }, + textStyle = UsdtDetailStyles.Body, + valueColor = if (settled) Colors.Usdt else Colors.Yellow, + iconColor = if (settled) Colors.Usdt else Colors.Yellow, + modifier = Modifier.weight(1f) + ) + UsdtDetailCell( + title = stringResource(R.string.usdt__receiving_network), + value = UsdtDestination.ARBITRUM.label, + icon = R.drawable.usdt_network, + modifier = Modifier.weight(1f) + ) + } + UsdtDetailCell( + title = stringResource(R.string.usdt__sender_network), + value = detail.deposit.network.depositNetwork()?.label ?: detail.deposit.network, + icon = R.drawable.usdt_network, + ) + UsdtDetailField(caption = stringResource(R.string.usdt__deposit_provider_status)) { + BodySSB(stringResource(R.string.usdt__deposit_status_note), style = UsdtDetailStyles.Body) + } +} + +@Composable +private fun UsdtDepositTechnicalDetails( + detail: UsdtDepositDetail, + address: String, + busy: Boolean, + onAddress: (String) -> Unit, +) { + DepositField(R.string.wallet__activity_tx_id, detail.deposit.sourceTx) + DepositField(R.string.usdt__deposit_reference, detail.deposit.id) + if (detail.deposit.asset != "USDT" || detail.deposit.network.depositNetwork() == null) { + BodyS(stringResource(R.string.usdt__deposit_wrong_asset), color = Colors.White64) + } else if (detail.canRefund) { + val network = detail.deposit.network.depositNetwork()?.refundLabel.orEmpty() + UsdtDetailField(caption = stringResource(R.string.usdt__deposit_refund_title)) { + BodySSB( + stringResource(R.string.usdt__deposit_refund_instructions).replace("{network}", network), + style = UsdtDetailStyles.Body + ) + } + TextInput( + value = address, + onValueChange = { if (!busy) onAddress(it) }, + enabled = !busy, + singleLine = true, + textStyle = UsdtDetailStyles.Body, + placeholderColor = Colors.White.copy(alpha = 0.5f), + placeholder = stringResource(R.string.usdt__deposit_refund_address).replace("{network}", network), + modifier = Modifier.fillMaxWidth().height(52.dp).testTag("UsdtRefundAddress") + ) + } else { + UsdtDepositSummary(detail) + } + detail.statusCode?.let { BodyS(it, color = Colors.White64) } +} + +@Composable +private fun DepositSettlement(detail: UsdtDepositDetail, hideBalance: Boolean) { + val order = detail.order?.takeUnless { it.status == "refunded" } + order?.amountOut?.let { DepositField(R.string.usdt__deposit_batch, it.usdtText(hideBalance)) } + order?.destinationTx?.let { + UsdtDetailField(caption = stringResource(R.string.wallet__activity_tx_id) + " · Arbitrum One") { + SelectionContainer { BodySSB(it, style = UsdtDetailStyles.Body) } + } + } + (detail.order?.refundTx ?: detail.deposit.refundTx)?.let { DepositField(R.string.usdt__refund_tx, it) } + val sent = order?.amountIn + val received = order?.amountOut + if (sent != null && received != null && sent >= received) { + DepositField(R.string.usdt__deposit_cost, (sent - received).usdtText(hideBalance)) + } +} + +@Composable +private fun DepositField(title: Int, value: String) { + UsdtDetailField(caption = stringResource(title)) { + SelectionContainer { BodySSB(value, style = UsdtDetailStyles.Body) } + } +} + +@Composable +internal fun UsdtRefundDialog( + amount: ULong?, + hideBalance: Boolean, + network: UsdtDepositNetwork, + address: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + AppAlertDialog( + title = stringResource(R.string.usdt__deposit_refund_confirm), + text = stringResource(R.string.usdt__deposit_refund_confirmation) + .replace("{amount}", amount.usdtText(hideBalance)) + .replace("{network}", if (network == UsdtDepositNetwork.BSC) "${network.label} (BSC)" else network.label) + .replace("{address}", address) + .replace("{source}", network.refundLabel), + confirmText = stringResource(R.string.usdt__deposit_refund), + dismissText = stringResource(R.string.common__cancel), + onConfirm = onConfirm, + onDismiss = onCancel, + confirmTextColor = Colors.Brand, + dismissTextColor = Colors.Brand, + confirmTestTag = "UsdtRefundConfirm", + dismissTestTag = "UsdtRefundCancel", + ) +} + +private val UsdtDepositNetwork.refundLabel get() = if (this == UsdtDepositNetwork.BSC) "BSC" else label +internal val UsdtDepositDetail.providerStatus get() = order?.status ?: deposit.status +internal val UsdtDepositDetail.canRefund get() = deposit.asset == "USDT" && deposit.network.depositNetwork() != null && + providerStatus !in listOf("completed", "refunded", "refunding", "refund_requested") + +private fun ULong?.usdtText(hidden: Boolean) = ( + if (hidden) UiConstants.HIDE_BALANCE_SHORT else this?.let { usdtExactAmount(it) } ?: "—" + ) + " USDT" diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDetailStyles.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDetailStyles.kt new file mode 100644 index 0000000000..a91dd9a84f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDetailStyles.kt @@ -0,0 +1,40 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.ui.components.SendCell +import to.bitkit.ui.theme.AppTextStyles + +internal object UsdtDetailStyles { + private val lineHeight = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + val Caption = AppTextStyles.CaptionM.copy(letterSpacing = 1.sp, lineHeightStyle = lineHeight) + val Body = AppTextStyles.BodySSB.copy(letterSpacing = 0.sp, lineHeightStyle = lineHeight) + val Value = Body.copy(letterSpacing = 0.4.sp) + val Title = AppTextStyles.Title.copy(letterSpacing = 0.sp, lineHeightStyle = lineHeight) + val SmallButton = AppTextStyles.CaptionB.copy( + letterSpacing = 0.sp, + lineHeight = 13.sp, + lineHeightStyle = lineHeight + ) + val Button = Body.copy(letterSpacing = (-0.4).sp) + val HistoryTitle = AppTextStyles.BodyMSB.copy(letterSpacing = 0.sp, lineHeightStyle = lineHeight) + val HistorySubtitle = AppTextStyles.CaptionB.copy(letterSpacing = 0.sp, lineHeightStyle = lineHeight) +} + +@Composable +internal fun UsdtDetailField( + caption: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + SendCell( + caption = caption, + captionStyle = UsdtDetailStyles.Caption, + dividerSpacing = 15.dp, + content = content, + modifier = modifier + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtNetworkPicker.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtNetworkPicker.kt new file mode 100644 index 0000000000..c92635f474 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtNetworkPicker.kt @@ -0,0 +1,87 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.UsdtDestination +import kotlinx.collections.immutable.ImmutableList +import to.bitkit.R +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors + +@Composable +internal fun UsdtNetworkPicker( + destination: UsdtDestination, + destinations: ImmutableList, + onSelect: (UsdtDestination) -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().background( + Colors.Gray7 + ).padding(horizontal = 16.dp).testTag("UsdtNetworkPicker") + ) { + SheetTopBar(stringResource(R.string.usdt__receive_network_title), onBack = onBack) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(top = 8.dp) + ) { + destinations.forEach { network -> + UsdtNetworkRow(network.label, network == destination, onClick = { onSelect(network) }) + } + BodyS( + stringResource(R.string.usdt__send_network_note), + color = Colors.White64, + letterSpacing = 0.sp, + modifier = Modifier.padding(vertical = 16.dp) + ) + UsdtCoinIllustration(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + } +} + +@Composable +internal fun UsdtNetworkRow(title: String, selected: Boolean, onClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().background( + Colors.Gray6, + RoundedCornerShape(16.dp) + ).clickableAlpha(onClick = onClick).padding(16.dp).testTag("UsdtNetwork-$title") + ) { + Image(painterResource(R.drawable.tether_circle), null, modifier = Modifier.size(40.dp)) + BodyMSB(title, letterSpacing = 0.sp) + FillWidth() + if (selected) { + Icon( + painterResource(R.drawable.ic_check), + null, + tint = Colors.Usdt, + modifier = Modifier.size(20.dp) + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtReceiveAmountContent.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtReceiveAmountContent.kt new file mode 100644 index 0000000000..2ae43b1e07 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtReceiveAmountContent.kt @@ -0,0 +1,98 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.UsdtException +import to.bitkit.R +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.NumberPadInputHandler + +@Composable +internal fun ColumnScope.UsdtReceiveAmountContent( + amount: String, + allowEmpty: Boolean, + busy: Boolean, + error: Int?, + limits: UsdtException.DepositAmountOutOfRange?, + onAmount: (String) -> Unit, + onContinue: () -> Unit, +) { + Column(verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier.heightIn(min = 146.dp)) { + UsdtAmountHeader(amount.ifEmpty { "0" }) + depositAmountMessage(amount, limits, error)?.let { message -> + BodyS(message, color = Colors.Brand, modifier = Modifier.padding(vertical = 16.dp)) + } + } + HorizontalDivider() + VerticalSpacer(34.dp) + BoxWithConstraints(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f).fillMaxWidth()) { + NumberPad( + type = NumberPadType.DECIMAL, + availableHeight = maxHeight, + enabled = !busy, + onDeleteLongPress = { onAmount("") }, + onPress = { + onAmount( + NumberPadInputHandler.handleInput( + it, + amount, + maxLength = 21, + maxDecimals = PaykitAsset.USDT.decimals + ) + ) + }, + ) + } + VerticalSpacer(12.dp) + val validAmount = amount.isEmpty() && allowEmpty || runCatching { + PaykitAmount.parse(PaykitAsset.USDT, amount) + }.isSuccess + PrimaryButton( + stringResource(R.string.common__continue), + onClick = onContinue, + enabled = !busy && validAmount, + isLoading = busy, + modifier = Modifier.testTag("UsdtDepositCreate") + ) +} + +@Composable +private fun depositAmountMessage(amount: String, limits: UsdtException.DepositAmountOutOfRange?, error: Int?): String? { + val value = amount.toBigDecimalOrNull() + val minText = limits?.minUsdCents + val min = minText?.toBigDecimalOrNull()?.movePointLeft(2) + val maxText = limits?.maxUsdCents + val max = maxText?.toBigDecimalOrNull()?.movePointLeft(2) + return when { + value != null && minText != null && min != null && value < min -> stringResource( + R.string.usdt__deposit_minimum, + minText.usdCents() + ) + value != null && maxText != null && max != null && value > max -> stringResource( + R.string.usdt__deposit_maximum, + maxText.usdCents() + ) + error != null -> stringResource(error) + else -> null + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtReceiveContent.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtReceiveContent.kt new file mode 100644 index 0000000000..b7c62408be --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtReceiveContent.kt @@ -0,0 +1,121 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.screens.wallets.receive.CopyAddressCard +import to.bitkit.ui.screens.wallets.receive.CopyAddressType +import to.bitkit.ui.screens.wallets.receive.ReceiveQrView +import to.bitkit.ui.theme.AppShapes +import to.bitkit.ui.theme.Colors + +@Composable +internal fun ColumnScope.UsdtReceiveContent( + address: String, + uri: String, + error: Int?, + network: String, + onEdit: () -> Unit, + onNetwork: () -> Unit, +) { + var showDetails by rememberSaveable { mutableStateOf(false) } + BackHandler(enabled = showDetails) { showDetails = false } + val networkTitle = stringResource(R.string.usdt__receive_network, network) + val shareContent = "$networkTitle\n$uri" + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (uri.isNotEmpty()) { + if (showDetails) { + Column( + modifier = Modifier.fillMaxWidth().height(minOf(maxWidth, maxHeight)) + .clip(AppShapes.small).background(Colors.Black) + .verticalScroll(rememberScrollState()).padding(32.dp) + ) { + CopyAddressCard( + title = network + " " + stringResource(R.string.wallet__activity_address), + address = address, + type = CopyAddressType.ONCHAIN, + onClickEditInvoice = null, + testTag = "UsdtReceiveAddress", + accentColor = Colors.Usdt, + shareContent = shareContent, + ) + } + } else { + ReceiveQrView( + uri = uri, + copyText = uri, + qrLogoPainter = painterResource(R.drawable.tether_circle), + onClickEditInvoice = onEdit, + accentColor = Colors.Usdt, + shareContent = shareContent, + modifier = Modifier.fillMaxWidth().height( + minOf(maxHeight, maxWidth + ButtonSize.Small.height + 32.dp) + ) + ) + } + } else if (error != null) { + BodyS(stringResource(error), color = Colors.Brand) + } else { + GradientCircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + SecondaryButton( + text = networkTitle, + onClick = onNetwork, + size = ButtonSize.Small, + icon = { + Icon( + painterResource(R.drawable.usdt_network), + null, + tint = Colors.Usdt, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("UsdtReceiveNetwork") + ) + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(if (showDetails) R.string.wallet__receive_show_qr else R.string.common__show_details), + icon = if (showDetails) { + { + Icon( + painter = painterResource(R.drawable.ic_qr_purple), + contentDescription = null, + tint = Colors.White, + modifier = Modifier.size(16.dp) + ) + } + } else { + null + }, + onClick = { showDetails = !showDetails }, + modifier = Modifier.testTag("UsdtReceiveDetails") + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt new file mode 100644 index 0000000000..6b84b4b4b3 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt @@ -0,0 +1,768 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.UsdtBridgeProvider +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.usdtFormatAmount +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ext.getClipboardText +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.components.PaymentAddressInput +import to.bitkit.ui.components.PaymentRequestInvoiceNote +import to.bitkit.ui.components.PaymentRequestSummary +import to.bitkit.ui.components.PaymentReviewIllustration +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.RectangleButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.SendCell +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.scanner.QrScanningScreen +import to.bitkit.ui.screens.wallets.send.HourglassAnimation +import to.bitkit.ui.shared.UiConstants +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.sheets.NewTransactionSheetView +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.NumberPadInputHandler +import to.bitkit.ui.utils.withAccent +import kotlin.time.Duration.Companion.seconds + +@Suppress("CyclomaticComplexMethod") +@Composable +internal fun UsdtPaymentContent( + wallet: UsdtWalletState, + state: UsdtSendState, + page: UsdtPage, + recipient: String, + amount: String, + destination: UsdtDestination, + onRecipientChange: (String) -> Unit, + onAmountChange: (String) -> Unit, + onRecipientContinue: (String) -> Unit, + onPageChange: (UsdtPage) -> Unit, + onBack: () -> Unit, + onReview: () -> Unit, + onConfirm: () -> Unit, + onDone: () -> Unit, + onDetails: () -> Unit = {}, + onMaximum: (() -> Unit)? = null, + hideBalance: Boolean = false, + amountEditable: Boolean = true, + isPaymentRequest: Boolean = false, + paymentRequestNote: String? = null, + contact: PubkyProfile? = null, + onDestinationChange: ((UsdtDestination) -> Unit)? = null, + destinations: ImmutableList = Env.usdtDestinations.toImmutableList(), +) { + if (page == UsdtPage.WALLET) return + + var showNetworks by rememberSaveable { mutableStateOf(false) } + if (showNetworks) { + BackHandler { showNetworks = false } + UsdtNetworkPicker( + destination, + destinations, + onSelect = { network -> + showNetworks = false + onDestinationChange?.invoke(network) + }, + onBack = { showNetworks = false }, + ) + return + } + val onNetworkClick: (() -> Unit)? = onDestinationChange?.let { { showNetworks = true } } + val quote = state.quote + val status = wallet.transfers.firstOrNull { it.id == quote?.id }?.status + val completed = state.submitted && !state.busy && status == UsdtTransferStatus.CONFIRMED + var showSuccess by rememberSaveable(quote?.id) { mutableStateOf(false) } + LaunchedEffect(completed) { + if (completed) { + delay(1.seconds) + showSuccess = true + } + } + val showSubmitted = state.submitted && !state.busy + if (showSuccess && completed && quote != null) { + NewTransactionSheetView( + details = NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + activityId = quote.id, + usdtAmount = quote.amount, + ), + onCloseClick = onDone, + onDetailClick = onDetails, + hideBalance = hideBalance, + modifier = Modifier.fillMaxSize().testTag("UsdtSendSuccess") + ) + return + } + val bridgeNeedsAttention = status == UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION || + status == UsdtTransferStatus.BRIDGE_FAILED + val failed = status == UsdtTransferStatus.FAILED || status == UsdtTransferStatus.REPLACED + val title = when { + showSubmitted && status == UsdtTransferStatus.BRIDGE_REFUNDED -> R.string.usdt__deposit_refunded + showSubmitted && bridgeNeedsAttention -> R.string.usdt__bridge_attention + showSubmitted && failed -> R.string.wallet__send_error_tx_failed + showSubmitted && state.error != null -> R.string.wallet__payment_request_expired + showSubmitted -> R.string.usdt__submitted + quote != null && isPaymentRequest -> R.string.wallet__payment_request + quote != null -> R.string.wallet__send_review + page == UsdtPage.AMOUNT -> R.string.usdt__amount + else -> R.string.usdt__send_title + } + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + SheetTopBar(stringResource(title), onBack = onBack.takeUnless { showSubmitted || state.busy }, action = { + if (contact != null && !showSubmitted) { PubkyContactAvatar(profile = contact, size = 32.dp) } + }) + VerticalSpacer(if (quote != null || page == UsdtPage.AMOUNT) 8.dp else 16.dp) + when { + showSubmitted -> UsdtSubmittedContent( + quote, + failed, + bridgeNeedsAttention, + status == UsdtTransferStatus.BRIDGE_REFUNDED, + state.error, + onDetails, + onDone + ) + quote != null -> UsdtConfirmation( + quote, + state, + isPaymentRequest, + paymentRequestNote, + contact, + onConfirm, + completed, + onEditAmount = { onPageChange(UsdtPage.AMOUNT) }, + ) + page == UsdtPage.AMOUNT -> UsdtAmountContent( + amount, + destination, + wallet, + state, + onAmountChange, + onReview, + onMaximum, + hideBalance, + amountEditable, + onNetworkClick, + destinations + ) + page == UsdtPage.MANUAL -> UsdtManualContent( + recipient, + destination, + onRecipientChange, + onRecipientContinue, + state.error, + onNetworkClick, + destinations + ) + else -> UsdtRecipientContent( + destination, + onRecipientContinue, + onPageChange, + state.error, + onNetworkClick, + destinations + ) + } + } +} + +@Composable +private fun ColumnScope.UsdtSubmittedContent( + quote: UsdtQuote?, + failed: Boolean, + needsAttention: Boolean, + refunded: Boolean, + error: Int?, + onDetails: () -> Unit, + onDone: () -> Unit, +) { + quote?.let { UsdtAmountHeader(usdtFormatAmount(it.amount), displayAmount = usdtOverviewAmount(it.amount)) } + VerticalSpacer(32.dp) + BodyM( + stringResource( + error ?: when { + refunded -> R.string.usdt__bridge_refunded_description + needsAttention -> R.string.usdt__bridge_attention + failed -> R.string.usdt__send_failed_description + quote?.destination == UsdtDestination.ARBITRUM -> R.string.usdt__submitted_description + else -> R.string.usdt__bridge_submitted_description + } + ), + color = Colors.White64 + ) + FillHeight() + if (!failed && !needsAttention && !refunded) { + HourglassAnimation( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + FillHeight() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.wallet__send_details), + onClick = onDetails, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = stringResource(R.string.common__close), + onClick = onDone, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun UsdtNetwork( + destination: UsdtDestination, + onChange: (() -> Unit)?, + destinations: ImmutableList +) { + SendCell(caption = stringResource(R.string.usdt__network)) { + UsdtNetworkButton(destination, onChange, destinations) + } +} + +@Composable +internal fun UsdtNetworkButton( + destination: UsdtDestination, + onChange: (() -> Unit)?, + destinations: ImmutableList +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.height(28.dp).background(Colors.White.copy(alpha = 0.01f), RoundedCornerShape(12.dp)) + .border(1.dp, Colors.White16, RoundedCornerShape(12.dp)) + .clickable(enabled = onChange != null && destinations.size > 1) { onChange?.invoke() } + .padding(horizontal = 12.dp, vertical = 6.dp).testTag("UsdtNetwork") + ) { + Icon(painterResource(R.drawable.usdt_network), null, tint = Colors.Usdt, modifier = Modifier.size(16.dp)) + CaptionB(destination.label, color = Colors.White64, maxLines = 1, letterSpacing = 0.sp, lineHeight = 13.sp) + } +} + +@Composable +private fun UsdtRecipientContent( + destination: UsdtDestination, + onRecipientContinue: (String) -> Unit, + onPageChange: (UsdtPage) -> Unit, + error: Int?, + onNetworkClick: (() -> Unit)?, + destinations: ImmutableList, +) { + val context = LocalContext.current + var emptyClipboard by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxSize()) { + UsdtNetwork(destination, onNetworkClick, destinations) + if (!LocalInspectionMode.current) { + QrScanningScreen( + showsGradientBackground = false, + permissionDescription = stringResource(R.string.usdt__camera_permission_description), + permissionAccentColor = Colors.Usdt, + onScanSuccess = { it.text?.trim()?.let(onRecipientContinue) }, + modifier = Modifier.weight(1f) + ) + } else { FillHeight() } + RectangleButton( + label = stringResource(R.string.wallet__payment_request_paste), + icon = R.drawable.ic_clipboard_text, + iconTint = Colors.Usdt, + modifier = Modifier.testTag("UsdtPaste") + ) { + val value = context.getClipboardText()?.trim() + if (value.isNullOrBlank()) { + emptyClipboard = true + } else { + emptyClipboard = false + onRecipientContinue(value) + } + } + RectangleButton( + label = stringResource(R.string.wallet__recipient_manual), + icon = R.drawable.ic_pencil_simple, + iconTint = Colors.Usdt, + modifier = Modifier.testTag("UsdtManual") + ) { onPageChange(UsdtPage.MANUAL) } + error?.let { + BodyS( + stringResource(it), + color = Colors.Brand, + modifier = Modifier.testTag("UsdtRecipientError") + ) + } + if (emptyClipboard) BodyS(stringResource(R.string.wallet__send_clipboard_empty_text), color = Colors.Brand) + BodyS(stringResource(R.string.usdt__network_warning), color = Colors.White64) + } +} + +@Composable +private fun UsdtManualContent( + recipient: String, + destination: UsdtDestination, + onRecipientChange: (String) -> Unit, + onRecipientContinue: (String) -> Unit, + error: Int?, + onNetworkClick: (() -> Unit)?, + destinations: ImmutableList, +) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + Column(modifier = Modifier.fillMaxSize()) { + UsdtNetwork(destination, onNetworkClick, destinations) + VerticalSpacer(16.dp) + Caption13Up(stringResource(R.string.wallet__send_to), color = Colors.White64) + VerticalSpacer(8.dp) + PaymentAddressInput( + value = recipient, + onValueChange = onRecipientChange, + placeholder = stringResource(R.string.usdt__address), + modifier = Modifier.fillMaxWidth().weight(1f).focusRequester(focus).testTag("UsdtRecipient") + ) + VerticalSpacer(16.dp) + error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("UsdtRecipientError")) + VerticalSpacer(16.dp) + } + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = recipient.isNotBlank(), + onClick = { onRecipientContinue(recipient) }, + modifier = Modifier.testTag("UsdtRecipientContinue") + ) + } +} + +@Composable +private fun UsdtAmountContent( + amount: String, + destination: UsdtDestination, + wallet: UsdtWalletState, + state: UsdtSendState, + onAmountChange: (String) -> Unit, + onReview: () -> Unit, + onMaximum: (() -> Unit)?, + hideBalance: Boolean, + amountEditable: Boolean, + onNetworkClick: (() -> Unit)?, + destinations: ImmutableList, +) { + Column(modifier = Modifier.fillMaxSize()) { + UsdtAmountHeader(amount.ifEmpty { "0" }, modifier = Modifier.testTag("UsdtAmount")) + VerticalSpacer(32.dp) + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("UsdtError")) + VerticalSpacer(16.dp) + } + FlowRow( + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(end = 8.dp).align(Alignment.CenterVertically)) { + Caption13Up(stringResource(R.string.wallet__send_available), color = Colors.White64) + VerticalSpacer(4.dp) + BodySSB( + ( + if (hideBalance) { + UiConstants.HIDE_BALANCE_SHORT + } else { + wallet.balance?.let { + "$ " + usdtOverviewAmount(it) + } ?: "—" + } + ).withAccent(accentColor = Colors.White64) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterVertically), + ) { + NumberPadActionButton(text = "USDT", color = Colors.Usdt, enabled = false, onClick = {}) + if (onMaximum != null && amountEditable) { + NumberPadActionButton( + text = stringResource(R.string.common__max), + color = Colors.Usdt, + onClick = onMaximum, + enabled = !state.busy, + isLoading = state.preparingMaximum, + modifier = Modifier.testTag("UsdtMax") + ) + } + if (onNetworkClick != null) { + UsdtNetworkButton( + destination, + onNetworkClick.takeUnless { state.busy }, + destinations, + ) + } + } + } + VerticalSpacer(16.dp) + HorizontalDivider(color = Colors.White10) + BoxWithConstraints(contentAlignment = Alignment.Center, modifier = Modifier.weight(1f).fillMaxWidth()) { + NumberPad( + type = NumberPadType.DECIMAL, + availableHeight = maxHeight, + enabled = !state.busy && amountEditable, + onDeleteLongPress = { onAmountChange("") }, + onPress = { key -> + onAmountChange(NumberPadInputHandler.handleInput(key, amount, maxLength = 21, maxDecimals = 6)) + }, + ) + } + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onReview, + enabled = amount.isNotEmpty() && !state.busy, + isLoading = state.busy && !state.preparingMaximum, + modifier = Modifier.testTag("UsdtReview") + ) + } +} + +@Composable +private fun UsdtConfirmation( + quote: UsdtQuote, + state: UsdtSendState, + isPaymentRequest: Boolean, + paymentRequestNote: String?, + contact: PubkyProfile?, + onConfirm: () -> Unit, + completed: Boolean, + onEditAmount: () -> Unit, +) { + var showDetails by rememberSaveable(quote.id) { mutableStateOf(false) } + val swipeProgress = remember { mutableFloatStateOf(0f) } + val note = paymentRequestNote?.trim()?.takeIf { it.isNotEmpty() } + var headerHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val scrollState = rememberScrollState() + LaunchedEffect(showDetails) { scrollState.scrollTo(0) } + Column(modifier = Modifier.fillMaxSize()) { + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val availableHeight = maxHeight + Column( + verticalArrangement = if (showDetails) Arrangement.Top else Arrangement.SpaceBetween, + modifier = Modifier.verticalScroll(scrollState).heightIn(min = maxHeight) + ) { + UsdtReviewHeader( + quote, + contact, + note, + isPaymentRequest, + showDetails, + modifier = Modifier.onSizeChanged { headerHeight = it.height }, + ) { + if (!state.busy && !completed) onEditAmount() + } + Column(modifier = Modifier.fillMaxWidth()) { + if (!showDetails) { + PaymentReviewIllustration( + swipeProgress = { swipeProgress.floatValue }, + isUsdt = true, + modifier = Modifier.height( + if (quote.destination == UsdtDestination.ARBITRUM) { + 256.dp + } else { + (availableHeight - with(density) { headerHeight.toDp() }).coerceIn(0.dp, 256.dp) + } + ) + ) + } else { + UsdtBridgeCost(quote) + if (quote.destination != UsdtDestination.ARBITRUM) { + VerticalSpacer(16.dp) + UsdtFeeNote(quote) + } + } + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("UsdtError")) + } + if (quote.destination == UsdtDestination.ARBITRUM) { + UsdtReviewDetailsButton(showDetails) { showDetails = !showDetails } + } + } + } + } + UsdtReviewFooter(quote, showDetails) { showDetails = !showDetails } + SwipeToConfirm( + text = stringResource(R.string.wallet__send_swipe), + color = Colors.Usdt, + onConfirm = onConfirm, + enabled = !state.busy && !completed, + loading = state.busy && !completed, + confirmed = state.busy || state.authenticationRequired || completed, + progress = swipeProgress, + modifier = Modifier.fillMaxWidth().testTag("UsdtConfirm") + ) + } +} + +@Composable +private fun ColumnScope.UsdtReviewFooter(quote: UsdtQuote, showDetails: Boolean, onToggleDetails: () -> Unit) { + if (quote.destination != UsdtDestination.ARBITRUM) { + UsdtReviewDetailsButton(showDetails, onToggleDetails) + } else if (showDetails) { + UsdtFeeNote(quote) + } + UsdtMaximumTotal(quote) +} + +@Composable +private fun ColumnScope.UsdtReviewDetailsButton(showDetails: Boolean, onClick: () -> Unit) { + PrimaryButton( + text = stringResource( + if (showDetails) R.string.common__hide_details else R.string.common__show_details + ), + size = ButtonSize.Small, + onClick = onClick, + fullWidth = false, + color = Colors.White06, + enableGradient = false, + icon = { + Icon( + painterResource( + if (showDetails) R.drawable.ic_eye_slash else R.drawable.ic_speed_normal + ), + null, + tint = Colors.Usdt, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.align( + Alignment.CenterHorizontally + ).padding(vertical = 24.dp).testTag("UsdtReviewDetails") + ) +} + +@Composable +private fun UsdtMaximumTotal(quote: UsdtQuote) { + if (quote.destination == UsdtDestination.ARBITRUM) return + VerticalSpacer(16.dp) + SendCell( + caption = stringResource(R.string.usdt__maximum_total), + modifier = Modifier.testTag("UsdtMaximumTotal") + ) { BodySSB(usdtOverviewAmount(quote.amount + quote.maximumFee) + " USDT") } + VerticalSpacer(16.dp) +} + +@Composable +private fun UsdtFeeNote(quote: UsdtQuote) { + Caption( + stringResource( + if (quote.bridgeProvider == UsdtBridgeProvider.ORCHESTRA) { + R.string.usdt__bridge_estimate_note + } else { + R.string.usdt__fee_note + } + ), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp) + ) +} + +@Composable +private fun UsdtReviewHeader( + quote: UsdtQuote, + contact: PubkyProfile?, + note: String?, + isPaymentRequest: Boolean, + showDetails: Boolean, + modifier: Modifier = Modifier, + onEditAmount: () -> Unit, +) { + Column(modifier = modifier) { + if (quote.destination != UsdtDestination.ARBITRUM) { + Caption13Up(stringResource(R.string.usdt__you_send), color = Colors.White64) + VerticalSpacer(8.dp) + } + UsdtAmountHeader( + usdtFormatAmount(quote.amount), + displayAmount = usdtOverviewAmount(quote.amount), + onClick = onEditAmount + ) + if (quote.destination != UsdtDestination.ARBITRUM) { + VerticalSpacer(16.dp) + SendCell( + caption = stringResource(R.string.usdt__recipient_gets), + modifier = Modifier.testTag("UsdtRecipientAmount") + ) { + val estimate = if (quote.bridgeProvider == UsdtBridgeProvider.ORCHESTRA) "≈ " else "" + BodySSB(estimate + usdtOverviewAmount(quote.receivedAmount) + " USDT") + } + } + VerticalSpacer(32.dp) + if (showDetails) { + UsdtQuoteDetails(quote, contact) + note?.let { + VerticalSpacer(16.dp) + PaymentRequestInvoiceNote(it) + } + } else if (isPaymentRequest) { + PaymentRequestSummary(profile = contact, note = note, accentColor = Colors.Usdt) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun UsdtBridgeCost(quote: UsdtQuote) { + if (quote.bridgeProvider == UsdtBridgeProvider.ORCHESTRA) { + VerticalSpacer(16.dp) + SendCell(caption = stringResource(R.string.usdt__bridge_deducted_fee)) { + BodySSB(usdtOverviewAmount(quote.amount - minOf(quote.amount, quote.receivedAmount)) + " USDT") + } + } +} + +@Composable +private fun UsdtQuoteDetails(quote: UsdtQuote, contact: PubkyProfile?) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SendCell(caption = stringResource(R.string.usdt__pay_from), modifier = Modifier.weight(1f)) { + NumberPadActionButton(text = "USDT", color = Colors.Usdt, enabled = false, onClick = {}) + } + UsdtDetailCell( + stringResource(R.string.usdt__network), + quote.destination.label, + R.drawable.usdt_network, + modifier = Modifier.weight(1f), + minValueHeight = 28.dp + ) + } + VerticalSpacer(16.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + UsdtDetailCell( + stringResource( + if (quote.destination == UsdtDestination.ARBITRUM) { + R.string.wallet__activity_payment + } else { + R.string.usdt__you_send + } + ), + usdtOverviewAmount(quote.amount), + R.drawable.ic_user, + modifier = Modifier.weight(1f) + ) + UsdtDetailCell( + stringResource(R.string.wallet__activity_fee), + usdtOverviewAmount(quote.maximumFee), + R.drawable.ic_speed_normal, + modifier = Modifier.weight(1f) + ) + } + VerticalSpacer(16.dp) + if (contact != null) { + SendCell(caption = stringResource(R.string.wallet__payment_request_contact)) { + Row(verticalAlignment = Alignment.CenterVertically) { + PubkyContactAvatar(profile = contact, size = 20.dp) + HorizontalSpacer(4.dp) + BodySSB(contact.name, maxLines = 1) + } + } + VerticalSpacer(16.dp) + } + SendCell( + caption = stringResource(R.string.wallet__activity_address) + ) { SelectionContainer { BodySSB(quote.recipient) } } + quote.bridgeProvider?.let { provider -> + VerticalSpacer(16.dp) + SendCell(caption = stringResource(R.string.usdt__bridge_provider)) { + BodySSB(if (provider == UsdtBridgeProvider.ORCHESTRA) "Orchestra" else "USDT0") + } + } +} + +@Preview +@Composable +private fun Preview2() { + AppThemeSurface { + BottomSheetPreview { + Column(modifier = Modifier.sheetHeight().background(Colors.Gray7)) { + UsdtPaymentContent( + UsdtWalletState(), UsdtSendState(), UsdtPage.AMOUNT, "", "125", UsdtDestination.ARBITRUM, + {}, {}, {}, {}, {}, {}, {}, {} + ) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportActions.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportActions.kt new file mode 100644 index 0000000000..e7adc60cc9 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportActions.kt @@ -0,0 +1,68 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.synonym.bitkitcore.UsdtDepositDetail +import com.synonym.bitkitcore.UsdtTransfer +import to.bitkit.R +import to.bitkit.ext.setClipboardText +import to.bitkit.models.Toast +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.SecondaryButton + +@Composable +internal fun UsdtSupportActions(details: String, enabled: Boolean = true) { + val app = appViewModel ?: return + val context = LocalContext.current + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.wallet__send_error_support), + onClick = { app.navigateToReportIssue(details) }, + enabled = enabled, + modifier = Modifier.testTag("UsdtContactSupport") + ) + SecondaryButton( + text = stringResource(R.string.usdt__copy_details), + onClick = { + context.setClipboardText(details) + app.toast(type = Toast.ToastType.SUCCESS, title = context.getString(R.string.common__copied)) + }, + enabled = enabled, + modifier = Modifier.testTag("UsdtCopyDetails") + ) + } +} + +internal val UsdtTransfer.supportDetails: String + get() = buildList { + add("Asset: USDT") + add("Bridge: ${if (orchestra == null) "USDT0" else "Orchestra"}") + add("Source network: Arbitrum One") + add("Recipient network: ${destination.label}") + add("Status: $status") + orchestra?.quoteId?.let { add("Bridge reference: $it") } + txHash?.let { add("Source transaction: $it") } + bridgeGuid?.let { add("LayerZero message: $it") } + orchestra?.destinationTx?.let { add("Destination transaction: $it") } + orchestra?.refundTx?.let { add("Refund transaction: $it") } + }.joinToString("\n") + +internal val UsdtDepositDetail.supportDetails: String + get() = buildList { + add("Asset: ${deposit.asset}") + add("Bridge: Orchestra") + add("Source network: ${deposit.network}") + add("Recipient network: Arbitrum One") + add("Deposit reference: ${deposit.id}") + add("Status: ${order?.status ?: deposit.status}") + add("Source transaction: ${deposit.sourceTx}") + statusCode?.let { add("Status code: $it") } + order?.destinationTx?.let { add("Destination transaction: $it") } + (order?.refundTx ?: deposit.refundTx)?.let { add("Refund transaction: $it") } + }.joinToString("\n") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt new file mode 100644 index 0000000000..dc013998e8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt @@ -0,0 +1,330 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.annotation.StringRes +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.usdtFormatAmount +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.env.Env +import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.USat +import to.bitkit.repositories.PaykitPaymentRequestError +import to.bitkit.repositories.UsdtRepo +import to.bitkit.repositories.UsdtSelfSendError +import to.bitkit.repositories.parseUsdtPaymentRequest +import to.bitkit.utils.Logger +import to.bitkit.viewmodels.SanityWarning +import javax.inject.Inject + +@Suppress("TooManyFunctions") +@HiltViewModel +class UsdtViewModel @Inject constructor( + private val repo: UsdtRepo, + private val settingsStore: SettingsStore, +) : ViewModel() { + companion object { + private const val TAG = "UsdtViewModel" + } + + val destinations = flow { + repo.sendDestinations() + .onSuccess { emit(it) } + .onFailure { Logger.warn("Could not load USDT bridge destinations", it, context = TAG) } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + Env.usdtDestinations.toImmutableList() + ) + + val wallet = repo.state + val receivedTxs = repo.receivedTxs + val settings = settingsStore.data.catch { + if (it is CancellationException) throw it + settingsReadFailed(it) + emit(SettingsData(hideBalance = true)) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + SettingsData(hideBalance = true) + ) + var sendPayment: (suspend (UsdtQuote) -> Unit)? = null + set(value) { + if (value != null) requiresPaymentContext = true + field = value + } + private var requiresPaymentContext = false + private val _state = MutableStateFlow(UsdtSendState()) + private var maximumQuote: UsdtQuote? = null + private var preparedQuote: PreparedQuote? = null + val state = _state.asStateFlow() + + var isSendPresented: Boolean + get() = repo.isSendPresented + set(value) { + repo.isSendPresented = value + if (!value) preparedQuote = null + } + + fun shouldRefresh(enabled: Boolean): Boolean = repo.shouldRefresh(enabled) + + suspend fun refresh(history: Boolean = false) { + repo.refresh(history) + .onSuccess { _state.update { it.copy(refreshError = null) } } + .onFailure { error -> + Logger.warn("Failed to refresh USDT with '${error.javaClass.simpleName}'", context = TAG) + _state.update { it.copy(refreshError = error.messageResource()) } + } + } + + suspend fun waitForTransfer() { + val state = _state.value + val quote = state.quote ?: return + if (!state.submitted || !state.busy) return + try { + if (quote.destination != UsdtDestination.ARBITRUM) return + repo.waitForTransfer(quote.id) + .onSuccess { _state.update { it.copy(refreshError = null) } } + .onFailure { error -> + Logger.warn("Failed to check USDT payment", error, context = TAG) + _state.update { it.copy(refreshError = error.messageResource()) } + } + } finally { + _state.update { it.copy(busy = false) } + } + } + + fun isValidRecipient(value: String, destination: UsdtDestination) = parseUsdtPaymentRequest( + value, + destination + ).isSuccess + + fun validateRecipient(value: String, destination: UsdtDestination, onValid: (UsdtPaymentRequest) -> Unit) { + parseUsdtPaymentRequest(value, destination) + .onSuccess { request -> + _state.update { it.copy(error = null) } + onValid(request) + } + .onFailure { error -> _state.update { it.copy(error = error.messageResource()) } } + } + + fun quote(recipient: String, amount: String, destination: UsdtDestination) = viewModelScope.launch { + if (!_state.value.canQuote) return@launch + _state.update { UsdtSendState(busy = true, refreshError = it.refreshError) } + val maximum = maximumQuote?.takeIf { + it.recipient == recipient && it.destination == destination && usdtFormatAmount(it.amount) == amount + } + val sender = wallet.value.address + val reusable = preparedQuote?.takeIf { + sender.isNotEmpty() && it.sender == sender && it.amount == amount && + it.quote.recipient == recipient && it.quote.destination == destination && + it.quote.expiresAt > nowTimestamp().epochSecond.toULong() + 5u + }?.quote + preparedQuote = null + val result = when { + maximum == null -> { + maximumQuote = null + (reusable?.let { Result.success(it) } ?: repo.quote(recipient, amount, destination)) + .onSuccess { preparedQuote = PreparedQuote(it, amount, sender) } + } + maximum.expiresAt > nowTimestamp().epochSecond.toULong() + 5u -> Result.success(maximum) + else -> repo.quoteMaximum(recipient, destination) + } + result.onSuccess { quote -> _state.update { it.copy(quote = quote, busy = false) } } + .onFailure { error -> + Logger.warn("Failed to quote USDT with '${error.javaClass.simpleName}'", context = TAG) + _state.update { it.copy(error = error.messageResource(), busy = false) } + } + } + + fun quoteMaximum( + recipient: String, + destination: UsdtDestination, + onAmount: (String) -> Unit, + ) = viewModelScope.launch { + if (!_state.value.canQuote) return@launch + _state.update { UsdtSendState(busy = true, preparingMaximum = true, refreshError = it.refreshError) } + preparedQuote = null + repo.quoteMaximum(recipient, destination) + .onSuccess { quote -> + maximumQuote = quote + onAmount(usdtFormatAmount(quote.amount)) + _state.update { it.copy(busy = false, preparingMaximum = false) } + } + .onFailure { error -> + _state.update { it.copy(error = error.messageResource(), busy = false, preparingMaximum = false) } + } + } + + suspend fun readPaymentSettings(onLoaded: (SettingsData) -> Unit) { + runSuspendCatching { settingsStore.data.first() } + .onSuccess { + _state.update { state -> state.copy(error = null) } + onLoaded(it) + } + .onFailure(::settingsReadFailed) + } + + private fun settingsReadFailed(error: Throwable) { + Logger.warn("Failed to read payment settings", error, context = TAG) + _state.update { it.copy(error = R.string.usdt__error_storage) } + } + + fun confirm() = viewModelScope.launch { + if (_state.value.busy || _state.value.submitted) return@launch + if (_state.value.warning != null || _state.value.authenticationRequired) return@launch + val quote = _state.value.quote ?: return@launch + _state.update { it.copy(busy = true) } + var paymentSettings: SettingsData? = null + try { + readPaymentSettings { paymentSettings = it } + } finally { + _state.update { if (it.quote?.id == quote.id) it.copy(busy = false) else it } + } + val settings = paymentSettings ?: return@launch + if (_state.value.quote?.id != quote.id) return@launch + val warnings = quote.warnings(wallet.value.balance, settings.enableSendAmountWarning) + val warning = warnings.firstOrNull { it !in _state.value.confirmedWarnings } + if (warning != null) { + _state.update { it.copy(warning = warning) } + return@launch + } + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + _state.update { it.copy(authenticationRequired = true) } + } else { + send(quote) + } + } + + fun acceptWarning() { + val warning = _state.value.warning ?: return + _state.update { + it.copy( + warning = null, + confirmedWarnings = (it.confirmedWarnings + warning).toImmutableList() + ) + } + confirm() + } + + fun authenticationVerified() { + if (_state.value.authenticationRequired) { + val quote = _state.value.quote ?: return + _state.update { it.copy(authenticationRequired = false) } + send(quote) + } + } + fun cancelAuthentication() = _state.update { it.copy(authenticationRequired = false) } + fun edit() = _state.update { UsdtSendState(refreshError = it.refreshError) } + + private data class PreparedQuote(val quote: UsdtQuote, val amount: String, val sender: String) + + private fun send(quote: UsdtQuote) = viewModelScope.launch { + if (_state.value.quote?.id != quote.id) return@launch + if (_state.value.busy) return@launch + maximumQuote = null + preparedQuote = null + _state.update { it.copy(busy = true, error = null) } + runSuspendCatching { + val payment = sendPayment + if (requiresPaymentContext && payment == null) throw PaykitPaymentRequestError.RequestUnavailable + if (payment != null) payment(quote) else repo.send(quote).getOrThrow() + } + .onSuccess { + _state.update { it.copy(submitted = true) } + } + .onFailure { error -> + Logger.warn("Failed to send USDT with '${error.javaClass.simpleName}'", context = TAG) + val recorded = wallet.value.transfers.any { it.id == quote.id } + _state.update { + it.copy( + submitted = recorded, + busy = recorded, + quote = quote.takeIf { recorded }, + error = error.messageResource().takeIf { + !recorded || error is UsdtException.SubmissionDeadlineExpired + } + ) + } + } + } +} + +@Stable +data class UsdtSendState( + val preparingMaximum: Boolean = false, + val quote: UsdtQuote? = null, + val busy: Boolean = false, + val submitted: Boolean = false, + val authenticationRequired: Boolean = false, + val warning: SanityWarning? = null, + val confirmedWarnings: ImmutableList = persistentListOf(), + @StringRes val error: Int? = null, + @StringRes val refreshError: Int? = null, +) { + val canQuote: Boolean + get() = !busy && !submitted && !authenticationRequired && warning == null +} + +private fun UsdtQuote.warnings(balance: ULong?, warnOver100: Boolean): List = buildList { + if (balance != null && amount > balance / 2u) add(SanityWarning.OVER_HALF_BALANCE) + if (warnOver100 && amount > 100_000_000u) add(SanityWarning.VALUE_OVER_100_USD) + val totalCost = USat(maximumFee) + USat(USat(amount) - USat(receivedAmount)) + if (totalCost > 10_000_000u) add(SanityWarning.FEE_OVER_10_USD) + if (totalCost > amount / 2u) add(SanityWarning.FEE_OVER_HALF_VALUE) +} + +@StringRes +@Suppress("CyclomaticComplexMethod") +internal fun Throwable.messageResource(): Int = when (this) { + is PaykitPaymentRequestError.RequestExpired -> R.string.wallet__payment_request_expired + is PaykitPaymentRequestError -> R.string.wallet__payment_request_unavailable + is UsdtSelfSendError -> R.string.usdt__error_self_send + is UsdtException.NotConfigured -> R.string.usdt__error_configuration + is UsdtException.InvalidAmount -> R.string.usdt__error_amount + is UsdtException.BridgeAmountTooSmall -> R.string.usdt__error_bridge_amount + is UsdtException.InvalidAddress, is UsdtException.WrongNetwork -> R.string.usdt__error_address + is UsdtException.InsufficientBalance -> R.string.usdt__error_balance + is UsdtException.QuoteExpired -> R.string.usdt__error_expired + is UsdtException.SubmissionDeadlineExpired -> R.string.wallet__payment_request_expired + is UsdtException.PendingTransfer -> R.string.usdt__error_pending + is UsdtException.DepositNeedsAttention -> R.string.usdt__deposit_attention + is UsdtException.DepositNotFound -> R.string.usdt__deposit_not_found + is UsdtException.DepositAuthorizationRejected -> R.string.usdt__deposit_authorization + is UsdtException.DepositAmountOutOfRange -> R.string.usdt__deposit_amount_out_of_range + is UsdtException.UnsupportedRoute -> R.string.usdt__error_route + is UsdtException.InvalidCredentials -> R.string.usdt__error_wallet + is UsdtException.ClockSkew -> R.string.usdt__error_clock + is UsdtException.UnsupportedDelegation -> R.string.usdt__error_delegation + is UsdtException.BackupUnavailable -> R.string.usdt__error_backup + is UsdtException.InvalidBackup, is UsdtException.Storage -> R.string.usdt__error_storage + is UsdtException.RateLimited -> R.string.usdt__error_rate_limit + is UsdtException.LogRangeTooLarge, is UsdtException.InvalidResponse -> R.string.usdt__error_response + is UsdtException.TransactionRejected -> if (reason == "Paymaster fee collection failed") { + R.string.usdt__error_fee_collection + } else { + R.string.usdt__error_rejected + } + else -> R.string.usdt__error_network +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt new file mode 100644 index 0000000000..39fa41d13e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -0,0 +1,474 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.usdtFormatAmount +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BiometricsView +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BottomSheet +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.TabBar +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.WalletBalanceContent +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.AppAlertDialog +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.screens.wallets.activity.components.activityGroupTitleResource +import to.bitkit.ui.screens.wallets.receive.ReceiveTab +import to.bitkit.ui.screens.wallets.send.SendPinCheckScreen +import to.bitkit.ui.settingsViewModel +import to.bitkit.ui.shared.UiConstants +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberBiometricAuthSupported +import kotlin.time.Duration.Companion.seconds + +@Composable +fun RowScope.UsdtWalletBalance(onClick: () -> Unit, viewModel: UsdtViewModel = hiltViewModel()) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + WalletBalanceContent( + title = "USDT", + icon = { + Image( + painterResource(R.drawable.tether_circle), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + modifier = Modifier.clickableAlpha(onClick = onClick).padding(vertical = 4.dp) + .testTag("UsdtWallet") + ) { + BodyMSB( + if (settings.hideBalance) { + UiConstants.HIDE_BALANCE_SHORT + } else { + wallet.balance?.let { + usdtOverviewAmount(it) + } ?: "—" + } + ) + } +} + +@Suppress("CyclomaticComplexMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UsdtWalletScreen( + onBack: () -> Unit, + initialTransferId: String? = null, + initialRecipient: String = "", + initialAmount: String = "", + viewModel: UsdtViewModel = hiltViewModel() +) { + val app = appViewModel + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + var page by rememberUsdtPage(initialRecipient, state) + val sending = page in listOf(UsdtPage.RECIPIENT, UsdtPage.MANUAL, UsdtPage.AMOUNT) + DisposableEffect(viewModel, sending) { + if (sending) viewModel.isSendPresented = true + onDispose { if (sending) viewModel.isSendPresented = false } + } + var selectedTransferId by rememberSaveable(initialTransferId) { mutableStateOf(initialTransferId) } + var recipient by rememberSaveable { mutableStateOf(initialRecipient) } + var amount by rememberSaveable { mutableStateOf(initialAmount) } + var maximumAmount by rememberSaveable { mutableStateOf(null) } + val destinations by viewModel.destinations.collectAsStateWithLifecycle() + var destination by rememberSaveable { mutableStateOf(UsdtDestination.ARBITRUM) } + val dismissalBlocked = state.busy || state.authenticationRequired + val dismiss = { + if (!dismissalBlocked) { + viewModel.edit() + page = UsdtPage.WALLET + } + } + val back = { + when { + state.busy -> Unit + state.authenticationRequired -> viewModel.cancelAuthentication() + state.submitted -> dismiss() + state.quote != null -> viewModel.edit() + page in listOf(UsdtPage.AMOUNT, UsdtPage.MANUAL) -> { + viewModel.edit() + page = if (page == UsdtPage.AMOUNT) UsdtPage.MANUAL else UsdtPage.RECIPIENT + } + else -> dismiss() + } + } + BackHandler(enabled = page == UsdtPage.WALLET) { + if (selectedTransferId != null && initialTransferId == null) selectedTransferId = null else onBack() + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(lifecycle, state.submitted) { + if (state.submitted) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.waitForTransfer() + } + } + } + UsdtRefreshEffect( + viewModel, + pending = wallet.transfers.any { it.status == UsdtTransferStatus.PENDING }, + ) { + page == UsdtPage.WALLET + } + val selectedTransfer = wallet.transfers.firstOrNull { it.id == selectedTransferId } + if (selectedTransfer != null) { + UsdtActivityDetail(selectedTransfer, settings.hideBalance, wallet.address) { + if (initialTransferId == null) selectedTransferId = null else onBack() + } + } else { + UsdtWalletContent( + wallet = wallet, + error = state.refreshError, + hideBalance = settings.hideBalance, + onReceive = { + app?.requestUsdtEnable { + viewModel.edit() + app.showSheet(Sheet.Receive(initialTab = ReceiveTab.USDT)) + } + }, + onSend = { + app?.requestUsdtEnable { + recipient = "" + amount = "" + viewModel.edit() + page = UsdtPage.RECIPIENT + } + }, + onBack = onBack, + onActivityClick = { selectedTransferId = it.id }, + ) + } + + state.warning?.let { warning -> + AppAlertDialog( + title = stringResource(R.string.common__are_you_sure), + text = stringResource(warning.message), + confirmText = stringResource(R.string.wallet__send_yes), + dismissText = stringResource(R.string.common__cancel), + onConfirm = viewModel::acceptWarning, + onDismiss = viewModel::edit, + modifier = Modifier.testTag(warning.testTag) + ) + } + if (page != UsdtPage.WALLET) { + val blocked by rememberUpdatedState(dismissalBlocked) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden || !blocked }, + ) + BottomSheet( + onDismissRequest = dismiss, + sheetState = sheetState, + containerColor = Colors.Gray7, + dragHandleTopPadding = 8.dp, + ) { + BackHandler(onBack = back) + Column( + modifier = Modifier.sheetHeight(isModal = true).background(Colors.Gray7) + .navigationBarsPadding().imePadding() + .padding(bottom = 16.dp) + ) { + when { + state.authenticationRequired -> Box(modifier = Modifier.padding(horizontal = 16.dp)) { + UsdtAuthentication( + useBiometrics = settings.isBiometricEnabled, + onCancel = viewModel::cancelAuthentication, + onVerified = viewModel::authenticationVerified, + ) + } + + else -> UsdtPaymentContent( + wallet = wallet, state = state, page = page, recipient = recipient, amount = amount, + destination = destination, + destinations = destinations, + onDestinationChange = { + if (destination != it) { + if (amount == maximumAmount) amount = "" + maximumAmount = null + destination = it + if (recipient.isNotEmpty() && !viewModel.isValidRecipient(recipient, it)) { + page = UsdtPage.MANUAL + } + viewModel.edit() + } + }, + onRecipientChange = { + recipient = it + viewModel.edit() + }, + onAmountChange = { + amount = it + maximumAmount = null + }, + onRecipientContinue = { value -> + viewModel.validateRecipient(value, destination) { request -> + if (request.chainId != null) destination = UsdtDestination.ARBITRUM + recipient = request.recipient + amount = request.amount?.let { usdtFormatAmount(it) } ?: amount + page = UsdtPage.AMOUNT + } + }, + onPageChange = { + if (!state.busy) { + viewModel.edit() + page = it + } + }, + onBack = back, onReview = { viewModel.quote(recipient, amount, destination) }, + onMaximum = { + viewModel.quoteMaximum(recipient, destination) { + amount = it + maximumAmount = it + } + }, + onConfirm = { viewModel.confirm() }, onDone = dismiss, + hideBalance = settings.hideBalance, + onDetails = { + selectedTransferId = state.quote?.id + dismiss() + }, + ) + } + } + } + } +} + +@Composable +internal fun rememberUsdtPage(initialRecipient: String, state: UsdtSendState): MutableState { + val paymentInProgress = state.submitted || (state.busy && state.quote != null) + return rememberSaveable( + stateSaver = Saver( + save = { if (paymentInProgress) UsdtPage.WALLET.name else it.name }, + restore = { if (paymentInProgress) UsdtPage.AMOUNT else UsdtPage.valueOf(it) }, + ), + ) { + mutableStateOf(if (initialRecipient.isEmpty()) UsdtPage.WALLET else UsdtPage.AMOUNT) + } +} + +@Composable +private fun UsdtWalletContent( + wallet: UsdtWalletState, + error: Int?, + hideBalance: Boolean, + onReceive: () -> Unit, + onSend: () -> Unit, + onBack: () -> Unit, + onActivityClick: (UsdtTransfer) -> Unit = {}, +) { + val settings = settingsViewModel + val app = appViewModel + val allowSwipe by settings?.enableSwipeToHideBalance?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(false) + } + Box(modifier = Modifier.fillMaxSize()) { + UsdtCoinIllustration( + modifier = Modifier.align(Alignment.TopEnd) + .offset(x = 81.dp, y = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 4.dp) + .scale(scaleX = -1f, scaleY = 1f) + ) + Column { + AppTopBar( + titleText = "USDT", + onBackClick = onBack, + icon = R.drawable.tether_circle, + actions = { DrawerNavIcon() } + ) + LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + item { + VerticalSpacer(16.dp) + UsdtAmountHeader( + amount = wallet.balance?.let { usdtFormatAmount(it) } ?: "—", + displayAmount = wallet.balance?.let { usdtOverviewAmount(it) }, + hideBalance = hideBalance, + onToggleHide = if (allowSwipe) ({ settings?.setHideBalance(!hideBalance) }) else null, + modifier = Modifier.testTag("UsdtBalance") + ) + VerticalSpacer(32.dp) + error?.let { + BodyS(stringResource(it), color = Colors.Brand) + VerticalSpacer(16.dp) + } + } + itemsIndexed(wallet.transfers, key = { _, transfer -> transfer.id }) { index, transfer -> + val heading = activityGroupTitleResource(transfer.timestamp) + if (index == 0 || activityGroupTitleResource(wallet.transfers[index - 1].timestamp) != heading) { + Caption13Up( + stringResource(heading), + color = Colors.White64, + modifier = Modifier.padding(top = if (index == 0) 0.dp else 16.dp, bottom = 16.dp) + ) + } + UsdtActivityRow(transfer, hideBalance, onClick = { onActivityClick(transfer) }) + VerticalSpacer(16.dp) + } + item { + if (!wallet.historyComplete) { + BodyS( + stringResource(R.string.usdt__history_sync), + color = Colors.White64, + modifier = Modifier.fillMaxWidth().background(Colors.Black).testTag("UsdtHistorySync") + ) + } + VerticalSpacer(120.dp) + } + } + } + TabBar(onSendClick = onSend, onReceiveClick = onReceive, onScanClick = { + app?.showScannerSheet(showBackButton = false) + }) + } +} + +@Composable +internal fun UsdtCoinIllustration(modifier: Modifier = Modifier) { + Box(contentAlignment = Alignment.Center, modifier = modifier.size(256.dp)) { + Image( + painterResource(R.drawable.tether_coin), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.requiredSize(width = 304.76.dp, height = 228.57.dp) + ) + } +} + +internal val UsdtDestination.label: String get() = when (this) { + UsdtDestination.STABLE -> "Stable" + UsdtDestination.ETHEREUM -> "Ethereum" + UsdtDestination.ARBITRUM -> "Arbitrum One" + UsdtDestination.POLYGON -> "Polygon" + UsdtDestination.PLASMA -> "Plasma" + UsdtDestination.BASE -> "Base" + UsdtDestination.BSC -> "BNB Smart Chain" + UsdtDestination.SOLANA -> "Solana" + UsdtDestination.TRON -> "Tron" +} + +@Composable +internal fun UsdtRefreshEffect( + viewModel: UsdtViewModel, + pending: Boolean = false, + receiving: Boolean = false, + onReceived: () -> Boolean = { true }, +) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val app = appViewModel + val handleReceived by rememberUpdatedState(onReceived) + val isPending by rememberUpdatedState(pending) + val isReceiving by rememberUpdatedState(receiving) + LaunchedEffect(lifecycle, viewModel, app) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.receivedTxs.collect { transfer -> + if ((isReceiving || app?.currentSheet?.value == null) && handleReceived()) { + app?.showTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = transfer.amount, + usdtTransferId = transfer.id, + ) + ) + } + } + } + while (true) { + viewModel.refresh(history = true) + delay(if (isReceiving || isPending) 10.seconds else 30.seconds) + } + } + } +} + +@Composable +internal fun UsdtAuthentication(useBiometrics: Boolean, onCancel: () -> Unit, onVerified: () -> Unit) { + val supported = rememberBiometricAuthSupported() + var fallbackToPin by remember { mutableStateOf(false) } + if (useBiometrics && supported && !fallbackToPin) { + BiometricsView(onSuccess = onVerified, onFailure = { fallbackToPin = true }) + } else { + SendPinCheckScreen(onBack = onCancel, onSuccess = onVerified) + } +} + +internal enum class UsdtPage { + WALLET, + RECIPIENT, + MANUAL, + AMOUNT, +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + UsdtWalletContent(UsdtWalletState(balance = 100_000_000u), null, false, {}, {}, {}) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index b815aa3ce3..cc9eb8ad1a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.navigation.NavController import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.env.Env import to.bitkit.models.PrimaryDisplay import to.bitkit.models.TransactionSpeed import to.bitkit.models.transactionSpeedUiText @@ -96,6 +97,10 @@ fun SettingsScreen( val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() val contactPaymentsEnabled by settings.contactPaymentsEnabled.collectAsStateWithLifecycle() val isUpdatingContactPayments by settings.isUpdatingContactPayments.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { settings.refreshUsdtAvailability() } + val canDisableUsdt by settings.canDisableUsdt.collectAsStateWithLifecycle() + val isUsdtEnabled by settings.isUsdtEnabled.collectAsStateWithLifecycle() + val isUpdatingUsdt by settings.isUpdatingUsdt.collectAsStateWithLifecycle() val hardwareWallets by hwWalletViewModel.wallets.collectAsStateWithLifecycle() val languageUiState by languageViewModel.uiState.collectAsStateWithLifecycle() @@ -137,6 +142,9 @@ fun SettingsScreen( contactPaymentsEnabled = contactPaymentsEnabled, isUpdatingContactPayments = isUpdatingContactPayments, hardwareWalletCount = hardwareWallets.size, + isUsdtEnabled = isUsdtEnabled, + canDisableUsdt = canDisableUsdt, + isUpdatingUsdt = isUpdatingUsdt, ), securityState = SecurityTabState( isPinEnabled = isPinEnabled, @@ -169,6 +177,7 @@ fun SettingsScreen( SettingsEvent.ContactPaymentsClick -> { settings.setContactPaymentsEnabled(!contactPaymentsEnabled) } + SettingsEvent.UsdtClick -> settings.setUsdtEnabled(!isUsdtEnabled) SettingsEvent.QuickPayClick -> navController.navigateToQuickPaySettings(quickPayIntroSeen) SettingsEvent.BgPaymentsClick -> { if (bgPaymentsIntroSeen || notificationsGranted) { @@ -334,17 +343,6 @@ private fun GeneralTabContent( padding = PaddingValues(top = 16.dp), ) - if (state.isPaykitEnabled && state.isPubkyAuthenticated) { - SettingsSwitchRow( - title = stringResource(R.string.settings__general__enable_contact_payments), - isChecked = state.contactPaymentsEnabled, - icon = { SettingsIcon(R.drawable.ic_coins) }, - onClick = { onEvent(SettingsEvent.ContactPaymentsClick) }, - enabled = !state.isUpdatingContactPayments, - switchTestTag = "ContactPaymentsToggle", - modifier = Modifier.testTag("ContactPaymentsSettings") - ) - } SettingsButtonRow( title = stringResource(R.string.settings__general__speed), icon = { @@ -392,6 +390,29 @@ private fun GeneralTabContent( modifier = Modifier.testTag("HardwareWalletsSettings") ) + if (state.isPaykitEnabled && state.isPubkyAuthenticated) { + SettingsSwitchRow( + title = stringResource(R.string.settings__general__enable_contact_payments), + isChecked = state.contactPaymentsEnabled, + icon = { SettingsIcon(R.drawable.ic_coins) }, + onClick = { onEvent(SettingsEvent.ContactPaymentsClick) }, + enabled = !state.isUpdatingContactPayments, + switchTestTag = "ContactPaymentsToggle", + modifier = Modifier.testTag("ContactPaymentsSettings") + ) + } + + if (Env.isUsdtEnabled) { + SettingsSwitchRow( + title = stringResource(R.string.usdt__settings_title), + isChecked = state.isUsdtEnabled, + icon = { SettingsIcon(R.drawable.ic_currency_dollar) }, + onClick = { onEvent(SettingsEvent.UsdtClick) }, + enabled = state.canToggleUsdt, + switchTestTag = "UsdtSettingsToggle", + modifier = Modifier.testTag("UsdtSettings") + ) + } VerticalSpacer(32.dp) } } @@ -680,6 +701,7 @@ sealed interface SettingsEvent { data object TagsClick : SettingsEvent data object TransactionSpeedClick : SettingsEvent data object ContactPaymentsClick : SettingsEvent + data object UsdtClick : SettingsEvent data object QuickPayClick : SettingsEvent data object BgPaymentsClick : SettingsEvent data object HardwareWalletsClick : SettingsEvent @@ -729,7 +751,12 @@ data class GeneralTabState( val contactPaymentsEnabled: Boolean = false, val isUpdatingContactPayments: Boolean = false, val hardwareWalletCount: Int = 0, -) + val isUsdtEnabled: Boolean = false, + val canDisableUsdt: Boolean = false, + val isUpdatingUsdt: Boolean = false, +) { + val canToggleUsdt: Boolean get() = !isUpdatingUsdt && (!isUsdtEnabled || canDisableUsdt) +} @Immutable data class SecurityTabState( diff --git a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt index 7219da0ab5..15516ecbab 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt @@ -1,6 +1,7 @@ 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 @@ -11,13 +12,18 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -25,9 +31,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.rememberLottieComposition +import com.airbnb.lottie.compose.rememberLottieDynamicProperties +import com.airbnb.lottie.compose.rememberLottieDynamicProperty +import com.synonym.bitkitcore.usdtFormatAmount import to.bitkit.R import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -40,10 +50,13 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SHEET_INTRO_IMAGE_WIDTH_FRACTION import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.usdtOverviewAmount 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.ui.utils.localizedRandom import to.bitkit.viewmodels.AppViewModel @@ -52,6 +65,7 @@ import to.bitkit.viewmodels.AppViewModel fun NewTransactionSheet( appViewModel: AppViewModel, bottomSheetOverlayState: BottomSheetOverlayState, + hideBalance: Boolean, modifier: Modifier = Modifier, ) { val details by appViewModel.transactionSheet.collectAsStateWithLifecycle() @@ -59,16 +73,25 @@ fun NewTransactionSheet( CompositionLocalProvider(LocalBottomSheetOverlayState provides bottomSheetOverlayState) { BottomSheet( onDismissRequest = { appViewModel.hideNewTransactionSheet() }, + containerColor = if (details.usdtAmount != null) Colors.Gray7 else null, + dragHandleTopPadding = if (details.usdtAmount != null) 8.dp else 12.dp, ) { NewTransactionSheetView( details = details, + hideBalance = hideBalance, onCloseClick = { appViewModel.hideNewTransactionSheet() }, onDetailClick = { appViewModel.onClickActivityDetail() }, modifier = modifier .sheetHeight(isModal = true) - .gradientBackground() + .then( + if (details.usdtAmount != null) { + Modifier.background(Colors.Gray7) + } else { + Modifier.gradientBackground() + } + ) .navigationBarsPadding() .testTag("new_transaction_sheet") ) @@ -82,35 +105,36 @@ fun NewTransactionSheetView( onCloseClick: () -> Unit, onDetailClick: () -> Unit, modifier: Modifier = Modifier, + hideBalance: Boolean = false, ) { Box(modifier = modifier) { - val composition by rememberLottieComposition( - if (details.type == NewTransactionSheetType.ONCHAIN) { - LottieCompositionSpec.RawRes(R.raw.confetti_orange) - } else { - LottieCompositionSpec.RawRes(R.raw.confetti_purple) - } - ) - LottieAnimation( - composition = composition, - contentScale = ContentScale.Crop, - iterations = 100, - modifier = Modifier - .fillMaxSize() - .testTag("confetti_animation") - ) + TransactionConfetti(details) if (details.direction == NewTransactionSheetDirection.RECEIVED) { - Image( - painter = painterResource(R.drawable.coin_stack_5), - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .testTag("transaction_received_image") - .align(Alignment.BottomEnd) - .padding(bottom = 32.dp) - ) + if (details.usdtAmount != null) { + // The exported composition includes artwork outside its centered 256dp design slot. + Image( + painter = painterResource(R.drawable.usdt_coins_received), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(x = 13.5.dp) + .padding(bottom = 12.dp) + .size(width = 349.dp, height = 520.dp) + .testTag("transaction_received_image") + ) + } else { + Image( + painter = painterResource(R.drawable.coin_stack_5), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxWidth() + .testTag("transaction_received_image") + .align(Alignment.BottomEnd) + .padding(bottom = 32.dp) + ) + } } else { Image( painter = painterResource(R.drawable.check), @@ -132,67 +156,135 @@ fun NewTransactionSheetView( .testTag("transaction_content_column") .padding(horizontal = 16.dp), ) { - val titleText = when (details.type) { - NewTransactionSheetType.LIGHTNING -> when (details.direction) { - NewTransactionSheetDirection.SENT -> stringResource(R.string.wallet__send_sent) - else -> stringResource(R.string.wallet__instant_payment_received) - } - - NewTransactionSheetType.ONCHAIN -> when (details.direction) { - NewTransactionSheetDirection.SENT -> stringResource(R.string.wallet__send_sent) - else -> stringResource(R.string.wallet__payment_received) + val titleText = stringResource( + when { + details.usdtAmount != null -> if (details.direction == NewTransactionSheetDirection.SENT) { + R.string.usdt__payment_sent + } else { + R.string.usdt__payment_received + } + details.direction == NewTransactionSheetDirection.SENT -> R.string.wallet__send_sent + details.type == NewTransactionSheetType.LIGHTNING -> R.string.wallet__instant_payment_received + else -> R.string.wallet__payment_received } - } + ) SheetTopBar(titleText) - Spacer(modifier = Modifier.height(24.dp)) - - BalanceHeaderView( - sats = details.sats, - modifier = Modifier - .fillMaxWidth() - .testTag("ReceivedTransaction") - ) - - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(if (details.usdtAmount != null) 8.dp else 24.dp)) - if (details.direction == NewTransactionSheetDirection.SENT) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .testTag("sent_buttons_row") - ) { - SecondaryButton( - text = stringResource(R.string.wallet__send_details), - onClick = onDetailClick, - enabled = (details.activityId != null || details.paymentHashOrTxId != null) && - !details.isLoadingDetails, - modifier = Modifier - .weight(1f) - .testTag("Details") - ) - PrimaryButton( - text = stringResource(R.string.common__close), - onClick = onCloseClick, - modifier = Modifier - .weight(1f) - .testTag("Close") - ) - } + if (details.usdtAmount != null) { + UsdtAmountHeader( + amount = usdtFormatAmount(details.usdtAmount), + displayAmount = usdtOverviewAmount(details.usdtAmount), + hideBalance = hideBalance, + onClick = { + val hasDetails = details.activityId != null || details.paymentHashOrTxId != null || + details.usdtTransferId != null + if (!details.isLoadingDetails && hasDetails) onDetailClick() + }, + modifier = Modifier.testTag("ReceivedTransaction") + ) } else { - PrimaryButton( - text = localizedRandom(R.string.common__ok_random), - onClick = onCloseClick, - modifier = Modifier.testTag("ReceivedTransactionButton") + BalanceHeaderView( + sats = details.sats, + modifier = Modifier.fillMaxWidth().testTag("ReceivedTransaction") ) } + + Spacer(modifier = Modifier.weight(1f)) + + TransactionButtons(details, onCloseClick, onDetailClick) Spacer(modifier = Modifier.height(8.dp)) } } } +private val ConfettiShade = Color(0xFF8E8E93) + +@Composable +private fun TransactionConfetti(details: NewTransactionSheetDetails) { + val composition by rememberLottieComposition( + if (details.type == NewTransactionSheetType.ONCHAIN) { + LottieCompositionSpec.RawRes(R.raw.confetti_orange) + } else { + LottieCompositionSpec.RawRes(R.raw.confetti_purple) + } + ) + LottieAnimation( + composition = composition, + dynamicProperties = if (details.usdtAmount != null) { + rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = Colors.Usdt.toArgb(), + keyPath = arrayOf("**"), + ) + ) + } else { + null + }, + contentScale = ContentScale.Crop, + iterations = 100, + modifier = Modifier + .fillMaxSize() + .then(if (details.usdtAmount != null) Modifier.background(Colors.Black) else Modifier) + .testTag("confetti_animation") + ) + + if (details.usdtAmount != null) { + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient(listOf(ConfettiShade.copy(alpha = 0.16f), Color.Transparent)) + ) + ) + } +} + +@Composable +private fun TransactionButtons( + details: NewTransactionSheetDetails, + onCloseClick: () -> Unit, + onDetailClick: () -> Unit +) { + if (details.direction == NewTransactionSheetDirection.SENT || details.usdtTransferId != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .testTag("sent_buttons_row") + ) { + SecondaryButton( + text = stringResource(R.string.wallet__send_details), + onClick = onDetailClick, + enabled = !details.isLoadingDetails && ( + details.activityId != null || details.paymentHashOrTxId != null || details.usdtTransferId != null + ), + modifier = Modifier + .weight(1f) + .testTag("Details") + ) + PrimaryButton( + text = if (details.direction == NewTransactionSheetDirection.RECEIVED) { + localizedRandom(R.string.common__ok_random) + } else { + stringResource(R.string.common__close) + }, + onClick = onCloseClick, + modifier = Modifier + .weight(1f) + .testTag("Close") + ) + } + } else { + PrimaryButton( + text = localizedRandom(R.string.common__ok_random), + onClick = onCloseClick, + modifier = Modifier.testTag("ReceivedTransactionButton") + ) + } +} + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 64ec4d2c62..ab8b2607a4 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -80,6 +80,7 @@ import to.bitkit.ui.screens.wallets.send.SendPendingViewModel import to.bitkit.ui.screens.wallets.send.SendPinCheckScreen import to.bitkit.ui.screens.wallets.send.SendQuickPayScreen import to.bitkit.ui.screens.wallets.send.SendRecipientScreen +import to.bitkit.ui.screens.wallets.usdt.PaykitUsdtReview import to.bitkit.ui.screens.wallets.withdraw.WithdrawConfirmScreen import to.bitkit.ui.screens.wallets.withdraw.WithdrawErrorScreen import to.bitkit.ui.settings.support.SupportScreen @@ -107,6 +108,18 @@ internal fun NavController.navigateToCoinSelection() { navigateTo(SendRoute.CoinSelection) } +internal fun NavController.navigateToSendAmount(startDestination: SendRoute) { + navigateTo(SendRoute.Amount) { + if (startDestination == SendRoute.Confirm) { + popUpTo(SendRoute.Confirm) { inclusive = true } + } + } +} + +internal fun NavController.navigateToSendPending(route: SendRoute.Pending) { + navigateTo(route) { popUpTo(graph.id) { inclusive = true } } +} + @Suppress("CyclomaticComplexMethod") @Composable fun SendSheet( @@ -126,7 +139,7 @@ fun SendSheet( var routingCacheResetAttempted by rememberSaveable(startDestination) { mutableStateOf(false) } val shouldShowSyncOverlay = run { - if (preparingRequest != null) return@run false + if (preparingRequest != null || sendUiState.paykitUsesUsdt) return@run false if (sendUiState.hardwareWalletId != null) return@run false if (!lightningState.nodeLifecycleState.isRunning()) return@run true val hasAnyChannels = lightningState.channels.isNotEmpty() @@ -231,11 +244,12 @@ fun SendSheet( LaunchedEffect(appViewModel, navController) { appViewModel.sendEffect.collect { when (it) { - is SendEffect.NavigateToAmount -> navController.navigateTo(SendRoute.Amount) + is SendEffect.NavigateToAmount -> navController.navigateToSendAmount(startDestination) is SendEffect.NavigateToAddress -> navController.navigateTo(SendRoute.Address) is SendEffect.NavigateToScan -> navController.navigateTo(SendRoute.QrScanner) is SendEffect.NavigateToCoinSelection -> navController.navigateToCoinSelection() is SendEffect.NavigateToConfirm -> navController.navigateTo(SendRoute.Confirm) + is SendEffect.NavigateToUsdtReview -> navController.navigateTo(SendRoute.UsdtReview) is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) is SendEffect.PaymentSuccess -> { @@ -256,7 +270,7 @@ fun SendSheet( is SendEffect.NavigateToFeeCustom -> navController.navigateTo(SendRoute.FeeCustom) is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) is SendEffect.NavigateToContacts -> navController.navigateTo(SendRoute.ContactSelect) - is SendEffect.NavigateToPending -> navController.navigateTo( + is SendEffect.NavigateToPending -> navController.navigateToSendPending( SendRoute.Pending( it.paymentHash, it.amount, @@ -264,7 +278,7 @@ fun SendSheet( isOnchain = it.isOnchain, refusalReason = it.refusalReason, ) - ) { popUpTo(startDestination) { inclusive = true } } + ) is SendEffect.NavigateToError -> navController.navigateTo( SendRoute.errorFromFailure( failure = it.failure, @@ -298,11 +312,25 @@ fun SendSheet( appViewModel.clearActiveContactPaymentContext() navController.popBackStack() }, - onOpenPayment = { paymentRequest, publicKey, privatePaymentContext -> - appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) + onOpenPayment = { paymentRequest, publicKey, privatePaymentContext, endpoints -> + appViewModel.openContactPayment( + paymentRequest, + publicKey, + privatePaymentContext, + endpoints = endpoints + ) }, ) } + composableWithDefaultTransitions { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + PaykitUsdtReview( + uiState = uiState, + app = appViewModel, + onBack = { navController.popBackStack() }, + onDone = { appViewModel.hideSheet() } + ) + } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() @@ -399,7 +427,7 @@ fun SendSheet( isNodeRunning = uiState.hardwareWalletId != null || lightningState.nodeLifecycleState.isRunning(), canAutoStart = !isOffline && !shouldShowSyncOverlay, - canGoBack = startDestination != SendRoute.Confirm, + canGoBack = navController.previousBackStackEntry != null, onBack = { val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) if (!didPopToAmount && !navController.popBackStack()) { @@ -578,16 +606,14 @@ fun SendSheet( }, onPaymentPending = { paymentHash, amount, paymentRequest -> appViewModel.preserveContactPaymentContext(paymentHash) - navController.navigateTo( + navController.navigateToSendPending( SendRoute.Pending( paymentHash = paymentHash, amount = amount, retryRoute = SendRetryRoute.QuickPay, paymentRequest = paymentRequest, ) - ) { - popUpTo(startDestination) { inclusive = true } - } + ) }, onFallBackToConfirm = { appViewModel.resetQuickPay() @@ -782,6 +808,9 @@ sealed interface SendRoute { @Serializable data object Amount : DeepLinkStart + @Serializable + data object UsdtReview : InternalOnly + @Serializable data object QrScanner : DeepLinkStart diff --git a/app/src/main/java/to/bitkit/ui/sheets/UsdtIntroSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/UsdtIntroSheet.kt new file mode 100644 index 0000000000..fb9c2f8ee5 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/UsdtIntroSheet.kt @@ -0,0 +1,78 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.usdt.UsdtCoinIllustration +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun UsdtIntroSheet(onDecline: () -> Unit, onEnable: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.sheetHeight().background(Colors.Gray7).navigationBarsPadding().testTag("UsdtIntro") + ) { + SheetTopBar(stringResource(R.string.usdt__intro_title)) + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp) + ) { + UsdtCoinIllustration(modifier = Modifier.align(Alignment.CenterHorizontally)) + VerticalSpacer(32.dp) + Display( + stringResource(R.string.usdt__intro_headline).withAccent(accentColor = Colors.Usdt), + letterSpacing = (-2).sp + ) + VerticalSpacer(8.dp) + BodyM( + stringResource(R.string.usdt__intro_description), + color = Colors.White64, + letterSpacing = 0.sp, + ) + } + VerticalSpacer(32.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(horizontal = 16.dp)) { + SecondaryButton( + stringResource(R.string.usdt__intro_decline), + onClick = onDecline, + testTag = "UsdtIntroDecline", + modifier = Modifier.weight(1f) + ) + PrimaryButton( + stringResource(R.string.usdt__enable), + onClick = onEnable, + modifier = Modifier.weight(1f).testTag("UsdtIntroEnable") + ) + } + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { BottomSheetPreview { UsdtIntroSheet({}, {}) } } +} diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt index 58a6d4917f..4020d7fb7a 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt @@ -6,6 +6,7 @@ object Colors { // Accents val Brand = Color(0xFFFF4400) val Blue = Color(0xFF0085FF) + val Usdt = Color(0xFF009393) val Green = Color(0xFF75BF72) val Purple = Color(0xFFB95CE8) val Red = Color(0xFFE95164) @@ -48,6 +49,7 @@ object Colors { val Brand24 = Brand.copy(alpha = 0.24f) val Brand32 = Brand.copy(alpha = 0.32f) val Brand50 = Brand.copy(alpha = 0.50f) + val Usdt16 = Usdt.copy(alpha = 0.16f) val Green16 = Green.copy(alpha = 0.16f) val Green24 = Green.copy(alpha = 0.24f) val Green32 = Green.copy(alpha = 0.32f) diff --git a/app/src/main/java/to/bitkit/ui/theme/Defaults.kt b/app/src/main/java/to/bitkit/ui/theme/Defaults.kt index 811c807172..7fc0a6f60b 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Defaults.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Defaults.kt @@ -99,6 +99,14 @@ object AppSwitchDefaults { uncheckedIconColor = Colors.Gray4, ) + val colorsUsdt: SwitchColors + @Composable + get() = colors.copy( + checkedTrackColor = Colors.Usdt, + checkedBorderColor = Colors.Usdt, + checkedIconColor = Colors.Usdt, + ) + val colorsPurple: SwitchColors @Composable get() = SwitchDefaults.colors( diff --git a/app/src/main/java/to/bitkit/ui/utils/NumberPadInputHandler.kt b/app/src/main/java/to/bitkit/ui/utils/NumberPadInputHandler.kt new file mode 100644 index 0000000000..fcdb1b87f8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/NumberPadInputHandler.kt @@ -0,0 +1,57 @@ +package to.bitkit.ui.utils + +import to.bitkit.ui.components.KEY_DECIMAL +import to.bitkit.ui.components.KEY_DELETE + +object NumberPadInputHandler { + fun handleInput( + key: String, + current: String, + maxLength: Int, + maxDecimals: Int, + ): String { + return if (maxDecimals == 0) { + handleIntegerInput(key, current, maxLength) + } else { + handleDecimalInput(key, current, maxLength, maxDecimals) + } + } + + private fun handleIntegerInput(key: String, current: String, maxLength: Int): String { + if (key == KEY_DELETE) return current.dropLast(1) + + if (current == "0") return key + if (current.length >= maxLength) return current + + return current + key + } + + @Suppress("ReturnCount") + private fun handleDecimalInput( + key: String, + current: String, + maxLength: Int, + maxDecimals: Int, + ): String { + val parts = current.split(".", limit = 2) + val decimalPart = if (parts.size > 1) parts[1] else "" + + if (key == KEY_DELETE) { + if (current == "0.") return "" + return current.dropLast(1) + } + + if (current == "0" && key != ".") return key + + if (current.length >= maxLength) return current + + if (decimalPart.length >= maxDecimals) return current + + if (key == KEY_DECIMAL) { + if (current.contains(".")) return current + if (current.isEmpty()) return "0." + } + + return current + key + } +} diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt index b254013094..252c03ba5e 100644 --- a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt +++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt @@ -21,6 +21,7 @@ fun Throwable.localizedPubkyAuthMessage(context: Context): String? { private fun Throwable.pubkyAuthMessageResource() = when (this) { is PubkyRingAuthTimeoutError -> R.string.profile__auth_error_timeout is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url + PubkyAuthRequestError.InvalidPaymentDetails -> R.string.profile__auth_approval_usdt_unavailable PubkyAuthRequestError.RequesterChanged -> R.string.profile__auth_error_invalid_url PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 950ac0e986..6ed402f854 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -16,6 +16,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.UsdtRepo import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService @@ -47,6 +48,7 @@ class WipeWalletUseCase @Inject constructor( private val firebaseMessaging: FirebaseMessaging, private val migrationService: MigrationService, private val paykitSdkService: PaykitSdkService, + private val usdtRepo: UsdtRepo, ) { private val wipeMutex = Mutex() @@ -97,7 +99,7 @@ class WipeWalletUseCase @Inject constructor( step("clear migration data") { migrationService.cleanupAfterMigration() } step("clear Paykit address reservations") { privatePaykitAddressReservationRepo.clear() } step("wipe Pubky local state") { pubkyRepo.wipeLocalState() } - val keychainWiped = step("wipe keychain") { keychain.wipe() } + val keychainWiped = step("wipe USDT and keychain") { usdtRepo.wipe { keychain.wipe() } } step("delete FCM token") { firebaseMessaging.deleteToken() } step("wipe core data") { coreService.wipeData() } step("clear database") { db.clearAllTables() } diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/UsdtTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/UsdtTimedSheet.kt new file mode 100644 index 0000000000..e98a9e3773 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/UsdtTimedSheet.kt @@ -0,0 +1,20 @@ +package to.bitkit.utils.timedsheets.sheets + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SettingsStore +import to.bitkit.data.UsdtPreference +import to.bitkit.env.Env +import to.bitkit.ui.components.TimedSheetType +import to.bitkit.utils.timedsheets.TimedSheetItem +import javax.inject.Inject + +class UsdtTimedSheet @Inject constructor(private val settingsStore: SettingsStore) : TimedSheetItem { + override val type = TimedSheetType.USDT + override val priority = type.priority + + override suspend fun shouldShow() = Env.isUsdtEnabled && + settingsStore.data.first().usdtPreference == UsdtPreference.UNDECIDED + + override suspend fun onShown() = Unit + override suspend fun onDismissed() = Unit +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt index 124ed07631..ec96360b6a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt @@ -25,9 +25,9 @@ import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.AmountInputHandler import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.components.KEY_DECIMAL import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.utils.NumberPadInputHandler import java.math.BigDecimal import java.text.NumberFormat import java.util.Locale @@ -74,7 +74,7 @@ class AmountInputViewModel @Inject constructor( val maxLength = getMaxLength(currencyState) val maxDecimals = getMaxDecimals(currencyState) - val newText = handleInput(key = key, current = rawInputText, maxLength, maxDecimals) + val newText = NumberPadInputHandler.handleInput(key = key, current = rawInputText, maxLength, maxDecimals) if (newText == rawInputText && key != KEY_DELETE) { triggerErrorState(key) @@ -369,62 +369,6 @@ class AmountInputViewModel @Inject constructor( .toDoubleOrNull() ?.let { fiat -> amountInputHandler.convertFiatToSats(fiat) } } - - private fun handleInput( - key: String, - current: String, - maxLength: Int, - maxDecimals: Int, - ): String { - return if (maxDecimals == 0) { - handleIntegerInput(key, current, maxLength) - } else { - handleDecimalInput(key, current, maxLength, maxDecimals) - } - } - - private fun handleIntegerInput(key: String, current: String, maxLength: Int): String { - if (key == KEY_DELETE) return current.dropLast(1) - - if (current == "0") return key - if (current.length >= maxLength) return current - - return current + key - } - - @Suppress("ReturnCount") - private fun handleDecimalInput( - key: String, - current: String, - maxLength: Int, - maxDecimals: Int, - ): String { - val parts = current.split(".", limit = 2) - val decimalPart = if (parts.size > 1) parts[1] else "" - - if (key == KEY_DELETE) { - if (current == "0.") return "" - return current.dropLast(1) - } - - // Handle leading zeros - replace "0" with new digit but allow "0." - if (current == "0" && key != ".") return key - - // Limit to maxLength - if (current.length >= maxLength) return current - - // Limit decimal places - if (decimalPart.length >= maxDecimals) return current - - if (key == KEY_DECIMAL) { - // No multiple decimal symbols - if (current.contains(".")) return current - // Add leading zero - if (current.isEmpty()) return "0." - } - - return current + key - } } data class AmountInputUiState( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9803c2bb31..ecc4c5dc79 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTime::class) + package to.bitkit.viewmodels import android.content.Context @@ -26,6 +28,7 @@ import com.synonym.bitkitcore.OnChainInvoice import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection +import com.synonym.bitkitcore.UsdtQuote import com.synonym.paykit.PaymentRequestLifecycleState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -106,6 +109,7 @@ import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat +import to.bitkit.ext.nowMillis import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.runSuspendCatching @@ -119,11 +123,17 @@ import to.bitkit.ext.walletId import to.bitkit.ext.watchUntil import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.FeeRate +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwWalletVendor import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAmountConversion +import to.bitkit.models.PaykitAmountError +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPayment import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyContactLink import to.bitkit.models.PubkyProfile @@ -144,7 +154,6 @@ import to.bitkit.models.sanitizedQrLogValue import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork import to.bitkit.models.toTxType -import to.bitkit.models.HwFundingSignedTx import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -152,6 +161,7 @@ import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.IncomingPaykitPaymentRequestFailureReason @@ -159,8 +169,8 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate -import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendAttemptUnreadableError import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendNotDispatchedError @@ -181,6 +191,7 @@ import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionDraft import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.repositories.PaykitUsdtPaymentRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo @@ -196,9 +207,14 @@ import to.bitkit.repositories.QuickPayPaymentFailedError import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.UsdtPreferencesRepo +import to.bitkit.repositories.UsdtRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.repositories.incomingPaymentRequestFailureReason +import to.bitkit.repositories.onchainPaymentTerms +import to.bitkit.repositories.parseUsdtPaymentRequest +import to.bitkit.repositories.paykitRate import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService import to.bitkit.services.MigrationService @@ -228,10 +244,12 @@ import to.bitkit.utils.timedsheets.sheets.BackupTimedSheet import to.bitkit.utils.timedsheets.sheets.HighBalanceTimedSheet import to.bitkit.utils.timedsheets.sheets.NotificationsTimedSheet import to.bitkit.utils.timedsheets.sheets.QuickPayTimedSheet +import to.bitkit.utils.timedsheets.sheets.UsdtTimedSheet import java.math.BigDecimal import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds @@ -279,11 +297,15 @@ class AppViewModel @Inject constructor( private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, + private val usdtRepo: UsdtRepo, + val paykitUsdtPayments: PaykitUsdtPaymentRepo, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, private val notificationsSheet: NotificationsTimedSheet, private val quickPaySheet: QuickPayTimedSheet, + private val usdtSheet: UsdtTimedSheet, + private val usdtPreferences: UsdtPreferencesRepo, private val highBalanceSheet: HighBalanceTimedSheet, private val formatMoneyValue: FormatMoneyValue, private val widgetsRepo: WidgetsRepo, @@ -348,7 +370,7 @@ class AppViewModel @Inject constructor( amountContinuePending = true } - SendEvent.PaymentMethodSwitch -> { + SendEvent.PaymentMethodSwitch, is SendEvent.PaymentMethodSelect -> { if (fundingSourceSwitchPending) return fundingSourceSwitchPending = true } @@ -406,6 +428,71 @@ class AppViewModel @Inject constructor( private val processedPayments = mutableSetOf() private val contactPaymentContextLock = Any() private var activeContactPaymentContext: ContactPaymentContext? = null + val usdtWallet = usdtRepo.state + val isUsdtEnabled = usdtPreferences.enabled.stateIn(viewModelScope, SharingStarted.Eagerly, false) + val isUsdtVisible = usdtPreferences.visible.stateIn(viewModelScope, SharingStarted.Eagerly, false) + var showUsdtEnablePrompt by mutableStateOf(false) + private set + private var pendingUsdtAction: (suspend () -> Unit)? = null + private var pendingUsdtCancel: (() -> Unit)? = null + private var isEnablingUsdt = false + + fun requestUsdtEnable(onCancel: () -> Unit = {}, onEnabled: suspend () -> Unit) { + if (isUsdtEnabled.value) { + viewModelScope.launch { onEnabled() } + return + } + pendingUsdtAction = onEnabled + pendingUsdtCancel = onCancel + showUsdtEnablePrompt = true + } + + fun dismissUsdtEnablePrompt() { + val onCancel = pendingUsdtCancel + clearUsdtEnablePrompt() + onCancel?.invoke() + } + + private fun clearUsdtEnablePrompt() { + pendingUsdtCancel = null + showUsdtEnablePrompt = false + pendingUsdtAction = null + } + + fun confirmUsdtEnable() { + val action = pendingUsdtAction ?: return + if (isEnablingUsdt) return + isEnablingUsdt = true + viewModelScope.launch { + try { + usdtPreferences.setEnabled(true).onSuccess { + isUsdtEnabled.first { it } + if (pendingUsdtAction !== action) return@onSuccess + clearUsdtEnablePrompt() + action() + }.onFailure { toast(it) } + } finally { + isEnablingUsdt = false + } + } + } + + var isPaykitUsdtBusy by mutableStateOf(false) + private set + + fun updatePaykitUsdtBusy(busy: Boolean) { isPaykitUsdtBusy = busy } + + val paykitReceivingMethods = combine( + settingsStore.data, + lightningRepo.lightningState, + usdtRepo.state, + ) { settings, _, _ -> + paykitPaymentRequestRepo.acceptedPaymentEndpointIdentifiers(settings) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private var paykitPaymentTerms: PaykitRequestPayment? = null + private var paykitReviewedAmount: PaykitAmount? = null + private var paykitAmountConversion = PaykitAmountConversion() private val pendingContactPaymentContexts = mutableMapOf() private val _requestedPaymentRequestId = MutableStateFlow(null) val requestedPaymentRequestId = _requestedPaymentRequestId.asStateFlow() @@ -438,6 +525,7 @@ class AppViewModel @Inject constructor( private val paymentRequestPresentationCompletionJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) + registerSheet(usdtSheet) registerSheet(backupSheet) registerSheet(notificationsSheet) registerSheet(quickPaySheet) @@ -516,6 +604,15 @@ class AppViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { + viewModelScope.launch { + usdtPreferences.enabled.distinctUntilChanged().collect { + updateCanSwitchWallet() + if (isPaykitEnabled.value) { + refreshPublicPaykitEndpointsIfEnabled() + refreshPrivatePaykitEndpointsIfEnabled("USDT availability", requeueActive = true) + } + } + } viewModelScope.launch { ToastEventBus.events.collect { toast(it) @@ -536,6 +633,11 @@ class AppViewModel @Inject constructor( notifyPaymentFailed((it as? QuickPayPaymentFailedError)?.reason) } } + viewModelScope.launch { + hwWalletRepo.wallets.collect { + if (_sendUiState.value.fundingOptions.isNotEmpty()) updateCanSwitchWallet() + } + } viewModelScope.launch { hwWalletRepo.receivedTxs.collect { tx -> showTransactionSheet( @@ -845,6 +947,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshPrivatePaykitEndpointsIfEnabled( reason: String, forceRefreshLightning: Boolean = false, + requeueActive: Boolean = false, ) { val contactKeys = pubkyRepo.contacts.value.map { it.publicKey } retryPendingPaykitEndpointRemoval(contactKeys, reason) @@ -856,7 +959,11 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } - privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) + privatePaykitRepo.refreshKnownSavedContactEndpoints( + reason, + forceRefreshLightning = forceRefreshLightning, + requeueActive = requeueActive, + ) if (isPaymentRequestPollingStopped.value) return refreshIncomingPaykitPaymentRequests(forceFresh = true, onlyWhileActive = true) if (!isPaymentRequestPollingStopped.value) refreshPaymentRequestTargets(force = true) @@ -988,6 +1095,7 @@ class AppViewModel @Inject constructor( return result.onSuccess { activityRepo.backfillPaykitContacts() clearHandledSubscriptionNotification() + if (mode == PaykitPaymentRequestRefreshMode.FULL) reconcilePaykitUsdt() presentNextIncomingPaykitPaymentRequest() } } @@ -1151,25 +1259,23 @@ class AppViewModel @Inject constructor( } } viewModelScope.launch { - paykitPaymentRequestRepo.pendingRequests.drop(1).collect { requests -> - retainPaymentRequestPresentationState(requests) - val activeRequest = activeIncomingPaymentRequest() - if ( - activeRequest != null && - !isSubmittingPaymentRequest && - uncertainOnchainPaymentRequestId != activeRequest.id - ) { - if (currentSheet.value is Sheet.Send && requests.none { it.id == activeRequest.id }) { - hideSheet() - } - } - if ( - isPaykitEnabled.value && paymentRequestIdentity != null && - PubkyPublicKeyFormat.matches(paymentRequestIdentity, pubkyRepo.publicKey.value) - ) { - presentNextIncomingPaykitPaymentRequest() - } - } + paykitPaymentRequestRepo.pendingRequests.drop(1).collect(::onPendingPaykitRequestsChanged) + } + } + + private suspend fun onPendingPaykitRequestsChanged(requests: List) { + retainPaymentRequestPresentationState(requests) + val activeRequest = activeIncomingPaymentRequest() + val isProtected = isSubmittingPaymentRequest || uncertainOnchainPaymentRequestId == activeRequest?.id || + isPreparedContactPayment(synchronized(contactPaymentContextLock) { activeContactPaymentContext }) + if (activeRequest != null && !isProtected) { + if (currentSheet.value is Sheet.Send && requests.none { it.id == activeRequest.id }) hideSheet() + } + if ( + isPaykitEnabled.value && paymentRequestIdentity != null && + PubkyPublicKeyFormat.matches(paymentRequestIdentity, pubkyRepo.publicKey.value) + ) { + presentNextIncomingPaykitPaymentRequest() } } @@ -1376,6 +1482,10 @@ class AppViewModel @Inject constructor( toast(it) return true } + val originalTerms = runCatching { proof.onchainPaymentTerms(request) }.getOrElse { + toast(it) + return true + } resetSendState( isPaymentRequest = true, paymentRequestNote = request.note, @@ -1388,6 +1498,8 @@ class AppViewModel @Inject constructor( ) { return true } + paykitPaymentTerms = originalTerms + paykitReviewedAmount = originalTerms.amount // The retained receipt authorizes only reopening the same signed payment, not preparing another spend. setActiveContactPaymentContext( ContactPaymentContext(publicKey = request.counterparty, incomingPaymentRequest = request), @@ -1463,6 +1575,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest = request, selectedTags = requestedPaymentRequestTags.takeIf { requestedPaymentRequestId.value == request.id } ?: persistentListOf(), + endpoints = result.endpoints, ) return true } @@ -2496,8 +2609,8 @@ class AppViewModel @Inject constructor( } finally { amountContinuePending = false } - SendEvent.PaymentMethodSwitch -> try { - onPaymentMethodSwitch() + SendEvent.PaymentMethodSwitch, is SendEvent.PaymentMethodSelect -> try { + onPaymentMethodSwitch((it as? SendEvent.PaymentMethodSelect)?.source) } finally { fundingSourceSwitchPending = false _sendUiState.update { state -> state.copy(isFundingSourceLoading = false) } @@ -2604,6 +2717,13 @@ class AppViewModel @Inject constructor( return@withContext } + if (activeHardwareWalletId == null && rejectUsdtSelfSend(input)) return@withContext + + if (isUsdtPaymentInput(input)) { + validateUsdtAddressWithFeedback(input) + return@withContext + } + val scanResult = runCatching { coreService.decode(input.removeLightningSchemes()) } if (scanResult.isFailure) { @@ -2740,6 +2860,30 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(isAddressInputValid = true) } } + private fun validateUsdtAddressWithFeedback(input: String) { + val request = if (Env.isUsdtEnabled) parseUsdtPaymentRequest(input).getOrNull() else null + when { + activeHardwareWalletId != null -> showHardwareOnchainOnlyValidationError() + request == null -> showAddressValidationError( + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.other__scan__error__generic, + testTag = "InvalidAddressToast", + ) + !rejectUsdtSelfSend(request.recipient) -> _sendUiState.update { it.copy(isAddressInputValid = true) } + } + } + + private fun rejectUsdtSelfSend(address: String): Boolean { + val ownAddress = usdtRepo.state.value.address + if (!Env.isUsdtEnabled || ownAddress.isBlank() || !address.equals(ownAddress, ignoreCase = true)) return false + showAddressValidationError( + titleRes = R.string.common__error, + descriptionRes = R.string.usdt__error_self_send, + testTag = "SelfPaymentToast", + ) + return true + } + private fun showHardwareOnchainOnlyValidationError() { showAddressValidationError( titleRes = R.string.hardware__send_onchain_only_title, @@ -2839,6 +2983,11 @@ class AppViewModel @Inject constructor( allowPubkyAuth: Boolean = isMainScanner, suppressQuickPay: Boolean = false, ): Job? { + if (contactPaymentContext == null && + isUsdtPaymentInput(data.trim()) && hasActiveUsdtSend() + ) { + return null + } val previousPreparation = paymentRequestPreparation.value?.takeUnless { it.request.id == contactPaymentContext?.incomingPaymentRequest?.id } @@ -2906,6 +3055,12 @@ class AppViewModel @Inject constructor( return nextJob } + private fun hasActiveUsdtSend(): Boolean = usdtRepo.isSendPresented || + ( + currentSheet.value is Sheet.Send && _sendUiState.value.usdtRecipient != null && + hasActiveContactPaymentContext() + ) + private suspend fun awaitPubkyDeeplinkInitialization( source: ScanSource, data: String, @@ -3062,7 +3217,212 @@ class AppViewModel @Inject constructor( launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true) } + fun onUsdtAmountChange(value: String) { + if (activeIncomingPaymentRequest() != null) return + _sendUiState.update { it.copy(paykitAmount = value, isAmountInputValid = validUsdtAmount(value)) } + } + + suspend fun refreshPaykitRequestAmount() { + val original = activeIncomingPaymentRequest() ?: return + val request = paykitPaymentRequestRepo.pendingRequests.value.firstOrNull { it.id == original.id } ?: original + if (request != original) { + synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.takeIf { it.incomingPaymentRequest?.id == original.id }?.let { + activeContactPaymentContext = it.copy(incomingPaymentRequest = request) + } + } + } + val target = if (_sendUiState.value.paykitUsesUsdt) PaykitAsset.USDT else PaykitAsset.BTC + val converted = runCatching { + request.payment(_sendUiState.value.paykitMethod(), Clock.System.now()) + .takeIf { it.isValid(Clock.System.now()) }?.amount + }.getOrNull() + _sendUiState.update { state -> + val sats = if (target == PaykitAsset.BTC) converted?.atomic ?: 0u else state.amount + val tokenAmount = if (target == PaykitAsset.USDT) converted?.value.orEmpty() else state.paykitAmount + state.copy( + paykitRateUnavailable = converted == null, + amount = sats, + paykitAmount = tokenAmount, + isAmountInputValid = converted != null && if (state.paykitUsesUsdt) { + validUsdtAmount(tokenAmount) + } else { + validateAmount(sats) + } + ) + } + } + + private fun validUsdtAmount(value: String): Boolean = runCatching { + PaykitAmount.parseInput(PaykitAsset.USDT, value).atomic <= (usdtRepo.state.value.balance ?: 0u) + }.getOrDefault(false) + + suspend fun sendPaykitUsdt(quote: UsdtQuote) { + val current = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + ?.takeUnless { isSubmittingPaymentRequest } ?: throw PaykitPaymentRequestError.RequestUnavailable + isSubmittingPaymentRequest = true + val reviewedAmount = paykitReviewedAmount + val paymentTerms = paykitPaymentTerms + try { + paykitUsdtPayments.prepare(current, quote, reviewedAmount, paymentTerms).getOrThrow() + if (synchronized(contactPaymentContextLock) { activeContactPaymentContext } != current || + !prepareContactPayment(current) || + synchronized(contactPaymentContextLock) { activeContactPaymentContext } != current + ) { + throw PaykitPaymentRequestError.RequestUnavailable + } + paykitUsdtPayments.send( + quote, + submissionDeadline = current.incomingPaymentRequest?.submissionDeadline(paymentTerms), + ) { authorizeUsdtExecution(current, reviewedAmount, paymentTerms) }.getOrThrow() + } finally { + isSubmittingPaymentRequest = false + } + } + + private suspend fun authorizeUsdtExecution( + current: ContactPaymentContext, + reviewedAmount: PaykitAmount?, + paymentTerms: PaykitRequestPayment?, + ) { + current.incomingPaymentRequest?.let { paykitPaymentRequestRepo.ensurePaymentAllowed(it).getOrThrow() } + if (synchronized(contactPaymentContextLock) { activeContactPaymentContext } != current || + paykitReviewedAmount != reviewedAmount || paykitPaymentTerms != paymentTerms + ) { + throw PaykitPaymentRequestError.RequestUnavailable + } + current.incomingPaymentRequest?.let { request -> + val now = Clock.System.now() + val terms = request.payment(MethodId.UsdtArbitrum, now, paymentTerms?.quoteId) + if (terms != paymentTerms || !terms.isValid(now) || terms.amount != reviewedAmount) { + throw PaykitPaymentRequestError.RequestUnavailable + } + } + } + + suspend fun reconcilePaykitUsdt() { + if (!Env.isUsdtEnabled || (!isUsdtEnabled.value && !usdtRepo.hasStoredWallet())) return + val previousReceipts = paykitUsdtPayments.receipts.value + paykitUsdtPayments.reconcile().onSuccess { + if (previousReceipts != paykitUsdtPayments.receipts.value) { + paykitPaymentRequestRepo.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) + } + }.onFailure { + Logger.warn("Failed to reconcile Paykit USDT payments", it, context = TAG) + } + } + + private fun needsUsdtEnable(hasUsdt: Boolean, resumesPayment: Boolean, usdtOnly: Boolean) = + hasUsdt && !isUsdtEnabled.value && !resumesPayment && usdtOnly + + private fun enableUsdtForContactPayment(payment: ContactPaymentContext, fromMainScanner: Boolean) { + requestUsdtEnable(onCancel = { cancelUsdtContactPayment(payment) }) { + if (synchronized(contactPaymentContextLock) { activeContactPaymentContext } == payment) { + openPaykitEndpoints(payment, fromMainScanner) + } + } + } + + private fun cancelUsdtContactPayment(payment: ContactPaymentContext) { + if (synchronized(contactPaymentContextLock) { activeContactPaymentContext } != payment) return + clearActiveContactPaymentContext(retryIncomingRequest = false) + _sendUiState.update { it.copy(isLoading = false) } + _currentSheet.update { sheet -> + val review = (sheet as? Sheet.Subscription)?.route as? SubscriptionRoute.Review + val request = payment.incomingPaymentRequest + if (payment.isInitialSubscriptionPayment && request != null && + review?.id == PaykitSubscriptionId(request.paymentRequestId, request.counterparty) + ) { + null + } else { + sheet + } + } + } + + private suspend fun openPaykitEndpoints(payment: ContactPaymentContext, fromMainScanner: Boolean) { + val decoded = payment.endpoints.filter { it.methodId != MethodId.UsdtArbitrum } + .map { coreService.decode(it.paymentRequest) } + val address = decoded.filterIsInstance().lastOrNull()?.invoice?.address.orEmpty() + val invoice = decoded.filterIsInstance().lastOrNull()?.invoice + val lnurl = decoded.filterIsInstance().lastOrNull()?.let { LnurlParams.LnurlPay(it.data) } + val usdtEndpoint = payment.endpoints.firstOrNull { it.methodId == MethodId.UsdtArbitrum } + val resumesUsdtPayment = payment.incomingPaymentRequest?.let { paykitUsdtPayments.hasBinding(it.id) } == true + if (needsUsdtEnable(usdtEndpoint != null, resumesUsdtPayment, decoded.isEmpty())) { + enableUsdtForContactPayment(payment, fromMainScanner) + return + } + val recipient = usdtEndpoint?.value?.takeIf { isUsdtEnabled.value || resumesUsdtPayment } + val hasBitcoinEndpoint = address.isNotEmpty() || invoice != null || lnurl != null + val canUseLightning = canFundPaykitLightning(invoice, lnurl) + val canUseOnchain = address.isNotEmpty() && + walletRepo.balanceState.value.maxSendOnchainSats > Defaults.dustLimit.toULong() + val useUsdt = recipient != null && prefersUsdt(payment, hasBitcoinEndpoint, canUseLightning || canUseOnchain) + val payMethod = paykitBitcoinMethod(canUseLightning, address, invoice, lnurl) + _sendUiState.update { + it.copy( + address = address, addressInput = address, isAddressInputValid = address.isNotEmpty(), + decodedInvoice = invoice, lnurl = lnurl, paykitUsesUsdt = useUsdt, usdtRecipient = recipient, + amount = invoice?.amountSatoshis?.takeIf { payMethod == SendMethod.LIGHTNING } ?: 0u, + paykitAmount = "", + payMethod = payMethod + ) + } + if (usdtRepo.shouldRefresh(isUsdtEnabled.value)) usdtRepo.refresh(history = false) + _sendUiState.update { + it.copy( + isAmountInputValid = if (it.paykitUsesUsdt) { + validUsdtAmount( + it.paykitAmount + ) + } else { + validateAmount(it.amount) + } + ) + } + refreshPaykitRequestAmount() + updateCanSwitchWallet() + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) + } + + private fun prefersUsdt( + payment: ContactPaymentContext, + hasBitcoinEndpoint: Boolean, + canUseBitcoin: Boolean, + ): Boolean { + val request = payment.incomingPaymentRequest + if (!hasBitcoinEndpoint || request?.let { paykitUsdtPayments.hasBinding(it.id) } == true) return true + val balance = usdtRepo.state.value.balance ?: 0u + if (request != null && request.amount.asset != PaykitAsset.BTC) { + val now = Clock.System.now() + val quoted = runCatching { request.payment(MethodId.UsdtArbitrum, now) }.getOrNull() + return quoted != null && quoted.isValid(now) && balance >= quoted.amount.atomic + } + return !canUseBitcoin && balance > 0u + } + + private fun canFundPaykitLightning(invoice: LightningInvoice?, lnurl: LnurlParams.LnurlPay?): Boolean { + if (invoice == null && lnurl == null) return false + val amount = invoice?.amountSatoshis?.coerceAtLeast(1u) ?: lnurl?.data?.minSendableSat() ?: 1u + return amount <= walletRepo.balanceState.value.maxSendLightningSats && lightningRepo.canSend(amount) + } + + private fun paykitBitcoinMethod( + canUseLightning: Boolean, + address: String, + invoice: LightningInvoice?, + lnurl: LnurlParams.LnurlPay?, + ): SendMethod { + val hasLightningEndpoint = invoice != null || lnurl != null + return if (canUseLightning || (address.isEmpty() && hasLightningEndpoint)) { + SendMethod.LIGHTNING + } else { + SendMethod.ONCHAIN + } + } + private suspend fun onAmountChange(amount: ULong) { + if (_sendUiState.value.paykitUsesUsdt || _sendUiState.value.fixedLightningAmount != null) return _sendUiState.update { it.copy( amount = amount, @@ -3102,7 +3462,9 @@ class AppViewModel @Inject constructor( onchainFeeUi = it.onchainFeeUi.copy(isLoading = true), ) } - setSendEffect(SendEffect.PopBack(SendRoute.Confirm)) + if (!reviewPaykitMethodChange(previous)) { + setSendEffect(SendEffect.PopBack(SendRoute.Confirm)) + } viewModelScope.launch { val shouldResetUtxos = when (settingsStore.data.first().coinSelectAuto) { true -> { @@ -3146,24 +3508,101 @@ class AppViewModel @Inject constructor( it.copy( canSwitchWallet = canSwitchWallet, canSwitchFundingSource = availableFundingSources(state).size > 1, + fundingOptions = fundingOptions(state), ) } } - private suspend fun onPaymentMethodSwitch() { + private suspend fun onPaymentMethodSwitch(requested: SendFundingSource? = null) { val current = _sendUiState.value if (current.isRetainedHardwarePayment) return - val sources = availableFundingSources(current) - if (sources.size < 2) return + val nextSource = nextFundingSource(current, requested) ?: return sendStateGeneration++ onchainSendRefreshJob?.cancel() - val selected = current.selectedFundingSource() - val selectedIndex = sources.indexOf(selected).takeIf { it >= 0 } ?: 0 - val nextSource = sources[(selectedIndex + 1) % sources.size] + val target = if (nextSource == SendFundingSource.Usdt) PaykitAsset.USDT else PaykitAsset.BTC + val converted = try { + paymentAmountForSource(current, nextSource) + } catch (_: PaykitAmountError.RateUnavailable) { + _sendUiState.update { it.copy(paykitRateUnavailable = true) } + return + } catch (_: PaykitAmountError) { + _sendUiState.update { it.copy(paykitRateUnavailable = false) } + return + } + _sendUiState.update { + it.copy( + isFundingSourceLoading = nextSource is SendFundingSource.Hardware, + paykitUsesUsdt = target == PaykitAsset.USDT, + paykitRateUnavailable = false, + amount = if (target == PaykitAsset.BTC) converted?.atomic ?: 0u else it.amount, + paykitAmount = if (target == PaykitAsset.USDT) converted?.value.orEmpty() else it.paykitAmount + ) + } + selectFundingSource(nextSource, current) _sendUiState.update { - it.copy(isFundingSourceLoading = nextSource is SendFundingSource.Hardware) + it.copy( + isAmountInputValid = if (it.paykitUsesUsdt) { + validUsdtAmount( + it.paykitAmount + ) + } else { + validateAmount(it.amount) + }, + ) } + updateCanSwitchWallet() + reviewPaykitMethodChange(current) + } + + private fun nextFundingSource(current: SendUiState, requested: SendFundingSource?): SendFundingSource? { + val sources = availableFundingSources(current).filter { requested != null || it != SendFundingSource.Usdt } + if (sources.isEmpty() || requested != null && requested !in sources) return null + val selected = current.selectedFundingSource() + return (requested ?: sources.nextAfter(selected)).takeUnless { it == selected } + } + + private fun reviewPaykitMethodChange(previous: SendUiState): Boolean { + if (previous.paykitMethod() == _sendUiState.value.paykitMethod() || + activeIncomingPaymentRequest()?.pricing?.conversion == null + ) { + return false + } + paykitPaymentTerms = null + paykitReviewedAmount = null + setSendEffect(SendEffect.NavigateToAmount) + return true + } + + private fun paymentAmountForSource(current: SendUiState, source: SendFundingSource): PaykitAmount? { + val target = if (source == SendFundingSource.Usdt) PaykitAsset.USDT else PaykitAsset.BTC + if (source == SendFundingSource.Spending) { + fixedContactLightningAmount(current)?.let { + return PaykitAmount(PaykitAsset.BTC, it) + } + } + val input = activeIncomingPaymentRequest()?.amount ?: if (current.paykitUsesUsdt) { + runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, current.paykitAmount) }.getOrNull() + } else { + PaykitAmount(PaykitAsset.BTC, current.amount) + } + return activeIncomingPaymentRequest()?.payment(current.paykitMethod(source), Clock.System.now())?.amount + ?: paykitAmountConversion.convert(input, target, currencyRepo.currencyState.value.paykitRate, nowMillis()) + } + + private fun fixedContactLightningAmount(state: SendUiState): ULong? = + state.decodedInvoice?.amountSatoshis?.takeIf { + it > 0u && state.lnurl == null && activeIncomingPaymentRequest() == null + } + + private suspend fun selectFundingSource(nextSource: SendFundingSource, current: SendUiState) { when (nextSource) { + SendFundingSource.Usdt -> _sendUiState.update { + it.copy( + hardwareWalletId = null, + hardwareWalletName = null + ) + } + SendFundingSource.Spending -> { _sendUiState.update { it.copy( @@ -3196,12 +3635,6 @@ class AppViewModel @Inject constructor( is SendFundingSource.Hardware -> selectHardwareFundingSource(nextSource.walletId, current) } - _sendUiState.update { - it.copy( - isAmountInputValid = validateAmount(it.amount), - ) - } - updateCanSwitchWallet() } private suspend fun selectHardwareFundingSource(walletId: String, current: SendUiState) { @@ -3253,6 +3686,8 @@ class AppViewModel @Inject constructor( fun switchToLightning() { viewModelScope.launch { sendStateGeneration++ + val previous = _sendUiState.value + val amount = fixedContactLightningAmount(previous) ?: previous.amount _sendUiState.update { it.copy( payMethod = SendMethod.LIGHTNING, @@ -3260,16 +3695,18 @@ class AppViewModel @Inject constructor( hardwareWalletName = null, hardwareAvailableSats = 0uL, lightningFeeSats = 0, - isAmountInputValid = validateAmount(it.amount, SendMethod.LIGHTNING), + amount = amount, + isAmountInputValid = validateAmount(amount, SendMethod.LIGHTNING), confirmedWarnings = persistentListOf(), ) } + if (reviewPaykitMethodChange(previous)) return@launch estimateLightningRoutingFeesIfNeeded() } } private suspend fun onAmountContinue() { - if (_sendUiState.value.isLoading) return + if (_sendUiState.value.isLoading || !reviewPaykitAmount()) return _sendUiState.update { it.copy( selectedUtxos = null, @@ -3310,6 +3747,54 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToConfirm) } + private suspend fun reviewPaykitAmount(): Boolean { + val paykit = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (paykit == null || paykit.endpoints.isEmpty()) return true + val current = _sendUiState.value + var terms: PaykitRequestPayment? = null + val payment = try { + val amount = enteredPaykitAmount(current) + terms = paykit.incomingPaymentRequest?.payment(current.paykitMethod(), Clock.System.now()) + if (terms?.isValid(Clock.System.now()) == false) throw PaykitAmountError.RateUnavailable + terms?.amount ?: amount + } catch (_: PaykitAmountError.RateUnavailable) { + _sendUiState.update { it.copy(paykitRateUnavailable = true, isAmountInputValid = false) } + return false + } catch (_: PaykitAmountError) { + _sendUiState.update { it.copy(paykitRateUnavailable = false, isAmountInputValid = false) } + return false + } + paykit.incomingPaymentRequest?.let { + val expected = payment + val displayed = enteredPaykitAmount(current) + if (expected != displayed) { + _sendUiState.update { state -> + state.copy( + amount = if (payment.asset == PaykitAsset.BTC) expected.atomic else state.amount, + paykitAmount = if (payment.asset == PaykitAsset.USDT) expected.value else state.paykitAmount, + paykitRateUnavailable = false, + isAmountInputValid = if (payment.asset == PaykitAsset.USDT) { + validUsdtAmount(expected.value) + } else { + validateAmount(expected.atomic) + }, + ) + } + return false + } + } + paykitReviewedAmount = payment + paykitPaymentTerms = terms + if (current.paykitUsesUsdt) setSendEffect(SendEffect.NavigateToUsdtReview) + return !current.paykitUsesUsdt + } + + private fun enteredPaykitAmount(current: SendUiState): PaykitAmount = if (current.paykitUsesUsdt) { + PaykitAmount.parseInput(PaykitAsset.USDT, current.paykitAmount) + } else { + PaykitAmount(PaykitAsset.BTC, current.amount) + } + private suspend fun prepareHardwareSendFee(): Boolean { val state = _sendUiState.value val walletId = state.hardwareWalletId ?: return true @@ -3447,6 +3932,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest: PaykitPaymentRequest? = null, isInitialSubscriptionPayment: Boolean = false, selectedTags: ImmutableList = persistentListOf(), + endpoints: List = emptyList(), ): Job? { val context = ContactPaymentContext( publicKey = publicKey, @@ -3454,6 +3940,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest = incomingPaymentRequest, isInitialSubscriptionPayment = isInitialSubscriptionPayment, selectedTags = selectedTags, + endpoints = endpoints, ) return launchScan( source = ScanSource.SCAN_RESULT, @@ -3483,17 +3970,11 @@ class AppViewModel @Inject constructor( ) = withContext(bgDispatcher) { if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext - val input = if (routePubkyKeys && PubkyContactLink.matches(Uri.parse(result))) { - PubkyContactLink.publicKey(Uri.parse(result)) ?: run { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__generic), - ) - return@withContext - } - } else { - result.removeLightningSchemes() + val input = normalizedScanInput(result, routePubkyKeys) ?: return@withContext + + if (contactPaymentContext == null && isUsdtPaymentInput(input.trim())) { + handleUsdtPaymentInput(input.trim()) + return@withContext } val contactPaymentProfile = activeContactPaymentProfile() @@ -3516,6 +3997,10 @@ class AppViewModel @Inject constructor( resetQuickPay() val fromMainScanner = isMainScanner + if (contactPaymentContext != null && requiresPaykitAssetSelection(contactPaymentContext)) { + openPaykitContactPayment(contactPaymentContext, fromMainScanner, suppressQuickPay) + return@withContext + } // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 if (Bip21Utils.isDuplicatedBip21(input)) { @@ -3593,6 +4078,52 @@ class AppViewModel @Inject constructor( handleDecodedScan(scan, input, fromMainScanner, suppressQuickPay) } + private fun normalizedScanInput(result: String, routePubkyKeys: Boolean): String? { + return if (routePubkyKeys && PubkyContactLink.matches(Uri.parse(result))) { + PubkyContactLink.publicKey(Uri.parse(result)) ?: run { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__scan_err_decoding), + description = context.getString(R.string.other__scan__error__generic), + ) + return null + } + } else { + result.removeLightningSchemes() + } + } + + private fun handleUsdtPaymentInput(input: String) { + val request = if (Env.isUsdtEnabled && activeHardwareWalletId == null && !usdtRepo.isSendPresented) { + parseUsdtPaymentRequest(input).getOrNull() + } else { + null + } + if (request == null) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + return + } + if (!isUsdtEnabled.value) { + requestUsdtEnable { handleUsdtPaymentInput(input) } + return + } + val amount = request.amount?.let { PaykitAmount(PaykitAsset.USDT, it).value }.orEmpty() + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.UsdtPayment(request.recipient, amount))) + } + + private fun requiresPaykitAssetSelection(payment: ContactPaymentContext): Boolean = + payment.endpoints.isNotEmpty() && ( + payment.endpoints.any { it.methodId == MethodId.UsdtArbitrum } || + payment.incomingPaymentRequest?.let { + it.amount.asset != PaykitAsset.BTC || it.pricing.conversion != null + } == true + ) + private fun logDecodedScan(scan: Scanner, isPaymentRequest: Boolean) { if (isPaymentRequest) { Logger.info("Decoded incoming Paykit payment request target", context = TAG) @@ -3609,20 +4140,7 @@ class AppViewModel @Inject constructor( suppressQuickPay: Boolean, ) { if (activeHardwareWalletId != null && scan != null && scan !is Scanner.OnChain) { - if (activeIncomingPaymentRequest() != null) { - clearActiveContactPaymentContext( - failureReason = IncomingPaykitPaymentRequestFailureReason.PaymentTargetNotRoutable, - ) - return - } - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.hardware__send_onchain_only_title), - description = context.getString(hardwareOnchainOnlyTextRes()), - ) - clearActiveContactPaymentContext( - failureReason = IncomingPaykitPaymentRequestFailureReason.PaymentTargetNotRoutable, - ) + rejectHardwarePaymentTarget() return } @@ -3885,9 +4403,10 @@ class AppViewModel @Inject constructor( } else { null } - val amount = incomingPaymentRequest?.amountSats - ?: lnInvoice?.amountSatoshis?.takeIf { it > 0uL } - ?: invoice.amountSatoshis + var amount = incomingPaymentRequest?.payment( + if (lnInvoice != null) MethodId.Bolt11 else PublicPaykitRepo.onchainMethodId(invoice.address), + Clock.System.now() + )?.amount?.atomic ?: lnInvoice?.amountSatoshis?.takeIf { it > 0uL } ?: invoice.amountSatoshis _sendUiState.update { it.copy( address = invoice.address, @@ -3911,8 +4430,13 @@ class AppViewModel @Inject constructor( if (incomingPaymentRequest != null) { if (lnInvoice != null) { waitForUsableChannels() - if (!lightningRepo.canSend(amount) && amount <= maxSendOnchain) { - _sendUiState.update { it.copy(payMethod = SendMethod.ONCHAIN) } + val onchainAmount = incomingPaymentRequest.payment( + PublicPaykitRepo.onchainMethodId(invoice.address), + Clock.System.now() + ).amount.atomic + if (!lightningRepo.canSend(amount) && onchainAmount <= maxSendOnchain) { + amount = onchainAmount + _sendUiState.update { it.copy(payMethod = SendMethod.ONCHAIN, amount = onchainAmount) } } } if (_sendUiState.value.payMethod == SendMethod.ONCHAIN && amount <= Defaults.dustLimit.toULong()) { @@ -4083,12 +4607,85 @@ class AppViewModel @Inject constructor( private fun maximumAvailableOnchainSats(selectedMax: ULong, hardwareWalletId: String?): ULong = if (hardwareWalletId == null) maxOf(selectedMax, maximumHardwareFundingBalanceSats()) else selectedMax + private fun rejectHardwarePaymentTarget() { + if (activeIncomingPaymentRequest() == null) { + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.hardware__send_onchain_only_title), + description = context.getString(hardwareOnchainOnlyTextRes()), + ) + } + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.PaymentTargetNotRoutable, + ) + } + + private suspend fun openPaykitContactPayment( + payment: ContactPaymentContext, + fromMainScanner: Boolean, + suppressQuickPay: Boolean, + ) { + if (activeHardwareWalletId == null) { + openPaykitEndpoints(payment, fromMainScanner) + return + } + val endpoint = payment.endpoints.firstOrNull { it.methodId.isOnchain } + if (endpoint == null) { + rejectHardwarePaymentTarget() + return + } + handleDecodedScan( + coreService.decode(endpoint.paymentRequest), + endpoint.paymentRequest, + fromMainScanner, + suppressQuickPay, + ) + } + + private fun List.nextAfter(selected: SendFundingSource): SendFundingSource { + val selectedIndex = indexOf(selected) + return this[(selectedIndex + 1) % size] + } + + private fun fundingOptions(state: SendUiState): ImmutableList { + val enabled = availableFundingSources(state) + return buildList { + add(SendFundingOption(SendFundingSource.Savings, enabled = SendFundingSource.Savings in enabled)) + add(SendFundingOption(SendFundingSource.Spending, enabled = SendFundingSource.Spending in enabled)) + hwWalletRepo.wallets.value.forEach { wallet -> + val source = SendFundingSource.Hardware(wallet.id) + add( + SendFundingOption( + source = source, + name = wallet.name, + balance = wallet.fundingBalanceSats, + enabled = source in enabled, + transportType = wallet.transportType, + isConnected = wallet.isConnected, + ) + ) + } + if (isUsdtEnabled.value || state.paykitUsesUsdt) { + add(SendFundingOption(SendFundingSource.Usdt, enabled = SendFundingSource.Usdt in enabled)) + } + }.toImmutableList() + } + private fun availableFundingSources(state: SendUiState): List = buildList { if (state.isRetainedHardwarePayment) { state.hardwareWalletId?.let { add(SendFundingSource.Hardware(it)) } return@buildList } - if (state.isUnified || state.decodedInvoice != null) add(SendFundingSource.Spending) + if (activeIncomingPaymentRequest()?.let { paykitUsdtPayments.hasBinding(it.id) } == true) { + if (state.usdtRecipient != null) add(SendFundingSource.Usdt) + return@buildList + } + if (state.usdtRecipient != null) add(SendFundingSource.Usdt) + if (state.isUnified || state.decodedInvoice != null || state.lnurl is LnurlParams.LnurlPay) { + add( + SendFundingSource.Spending + ) + } if (state.address.isNotEmpty()) { add(SendFundingSource.Savings) hwWalletRepo.wallets.value.forEach { wallet -> @@ -4099,7 +4696,19 @@ class AppViewModel @Inject constructor( } } + private fun SendUiState.paykitMethod( + source: SendFundingSource = selectedFundingSource(), + ): MethodId = when (source) { + SendFundingSource.Usdt -> MethodId.UsdtArbitrum + SendFundingSource.Spending -> when (lnurl) { + is LnurlParams.LnurlPay -> MethodId.Lnurl + else -> MethodId.Bolt11 + } + SendFundingSource.Savings, is SendFundingSource.Hardware -> PublicPaykitRepo.onchainMethodId(address) + } + private fun SendUiState.selectedFundingSource(): SendFundingSource = when { + paykitUsesUsdt -> SendFundingSource.Usdt hardwareWalletId != null -> SendFundingSource.Hardware(hardwareWalletId) payMethod == SendMethod.LIGHTNING -> SendFundingSource.Spending else -> SendFundingSource.Savings @@ -4134,7 +4743,11 @@ class AppViewModel @Inject constructor( return } - val amount = incomingPaymentRequest?.amountSats ?: invoice.amountSatoshis + val amount = incomingPaymentRequest?.let { + runCatching { + it.payment(MethodId.Bolt11, Clock.System.now()).amount.atomic + }.getOrNull() + } ?: invoice.amountSatoshis val quickPayHandled = handleQuickPayIfApplicable( amountSats = amount, invoice = invoice, @@ -4198,7 +4811,11 @@ class AppViewModel @Inject constructor( val isFixed = data.isFixedAmount() val displaySats = data.minSendableSat() - val incomingAmount = activeIncomingPaymentRequest()?.amountSats + val incomingAmount = activeIncomingPaymentRequest()?.let { + runCatching { + it.payment(MethodId.Lnurl, Clock.System.now()).amount.atomic + }.getOrNull() + } if (incomingAmount != null && incomingAmount !in displaySats..data.maxSendableSat()) { if (clearIncomingPaymentRequestTarget()) return toast( @@ -4310,13 +4927,14 @@ class AppViewModel @Inject constructor( effect: SendEffect, ) { val preparation = paymentRequestPreparation.value - if (preparation != null && route == SendRoute.Confirm) { + if (preparation != null) { if (!ownsPaymentRequestPreparation(preparation)) return if (activeIncomingPaymentRequest()?.id != preparation.request.id) return - paymentRequestPreparation.compareAndSet(preparation, null) + if (!paymentRequestPreparation.compareAndSet(preparation, null)) return _currentSheet.update { if (it === preparation.sheet) preparation.sheet.copy(preparingRequest = null) else it } + if (route != SendRoute.Confirm) setSendEffect(effect) return } if (fromMainScanner) { @@ -4733,7 +5351,7 @@ class AppViewModel @Inject constructor( requestId = incomingPaymentRequest?.id, payerIdentity = originalPayer, contactPublicKey = contactPaymentContext?.publicKey, - paymentDeadlineAt = incomingPaymentRequest?.paymentDeadlineAt, + paymentDeadlineAt = incomingPaymentRequest?.submissionDeadline(paykitPaymentTerms), beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted( @@ -4921,11 +5539,16 @@ class AppViewModel @Inject constructor( if (original.requestId != null && request == null) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } + val paymentDeadline = request?.let { + paykitPaymentProofRepo.authorizeOnchainRecovery(it, original).getOrElse { error -> + return Result.failure(error) + } + } val result = lightningRepo.retryOriginalOnchainSend( original.attemptId, original.walletId, feeRateSatsPerVByte, - paymentDeadlineAt = request?.paymentDeadlineAt, + paymentDeadlineAt = paymentDeadline, approvePrepared = { receipt -> val fee = requireNotNull(receipt.miningFeeSats) check(fee <= Long.MAX_VALUE.toULong()) @@ -4960,7 +5583,7 @@ class AppViewModel @Inject constructor( ?: throw PaykitPaymentRequestError.RequestUnavailable check(currentRequest.paymentDeadlineAt == request.paymentDeadlineAt) paykitPaymentRequestRepo.ensurePaymentAllowed(currentRequest).getOrThrow() - paykitPaymentProofRepo.authorizeOnchainRecovery(attempt).getOrThrow() + paykitPaymentProofRepo.authorizeOnchainRecovery(currentRequest, attempt).getOrThrow() } } if (result.getOrNull() is OnchainSendOutcome.Accepted) { @@ -5063,7 +5686,11 @@ class AppViewModel @Inject constructor( val lnurlComment = savePendingLnurlComment(decodedInvoice, paymentHash) var authorizationError: Throwable? = null - val result = sendLightning(decodedInvoice.bolt11, paymentAmount, incomingPaymentRequest?.paymentDeadlineAt) { + val result = sendLightning( + decodedInvoice.bolt11, + paymentAmount, + incomingPaymentRequest?.submissionDeadline(paykitPaymentTerms), + ) { authorizationError = incomingPaymentRequest?.let { paykitPaymentRequestRepo.ensurePaymentAllowed(it).exceptionOrNull() } @@ -5188,6 +5815,7 @@ class AppViewModel @Inject constructor( paymentEndpointIdentifier = preparation.endpointIdentifier, paymentAppId = preparation.appId, kind = preparation.kind, + paymentTerms = paykitPaymentTerms, ).map { request } } @@ -5213,6 +5841,7 @@ class AppViewModel @Inject constructor( ) { val paymentRequest = request ?: return if (preparation == null) return + val quoteId = paykitPaymentTerms?.quoteId viewModelScope.launch { paykitPaymentProofRepo.completeOnchainPayment( request = paymentRequest, @@ -5220,6 +5849,7 @@ class AppViewModel @Inject constructor( paymentEndpointIdentifier = preparation.endpointIdentifier, paymentAppId = preparation.appId, acceptedOutcome = acceptedOutcome, + conversionQuoteId = quoteId, ) if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } @@ -5233,7 +5863,7 @@ class AppViewModel @Inject constructor( ): Result = request?.let { val privateContext = contactPaymentContext?.privatePaymentContext paykitPaymentProofRepo.markOnchainPaymentStarted( - it, address, walletId, + it, address, _sendUiState.value.amount, walletId, privatePaymentListVersion = privateContext?.paymentListVersion, previousPrivatePaymentListVersion = privateContext?.previousPaymentListVersion, ) @@ -5248,14 +5878,7 @@ class AppViewModel @Inject constructor( activeContactPaymentContext }, ): PaymentProofPreparation { - val methodId = when (_sendUiState.value.payMethod) { - SendMethod.ONCHAIN -> PublicPaykitRepo.onchainMethodId(_sendUiState.value.address) - SendMethod.LIGHTNING -> if (_sendUiState.value.lnurl is LnurlParams.LnurlPay) { - MethodId.Lnurl - } else { - MethodId.Bolt11 - } - } + val methodId = _sendUiState.value.paykitMethod() val appId = contactPaymentContext?.privatePaymentContext?.paymentAppsByEndpoint?.get(methodId.rawValue) ?: throw PaykitPaymentRequestError.RequestUnavailable return PaymentProofPreparation( @@ -5267,31 +5890,54 @@ class AppViewModel @Inject constructor( private suspend fun hasMismatchedIncomingPaymentRequest(contactPaymentContext: ContactPaymentContext?): Boolean { val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest ?: return false - if (!incomingPaymentRequest.acceptsPaymentAmount(_sendUiState.value.amount)) return true - if (_sendUiState.value.payMethod != SendMethod.LIGHTNING) return false - - val lightningInvoice = _sendUiState.value.decodedInvoice ?: return false - return !incomingPaymentRequest.acceptsLightningInvoice(lightningInvoice) + if (_sendUiState.value.paykitUsesUsdt) { + return runCatching { + val terms = incomingPaymentRequest.payment( + MethodId.UsdtArbitrum, + Clock.System.now(), + paykitPaymentTerms?.quoteId + ) + terms != paykitPaymentTerms || !terms.isValid(Clock.System.now()) || + terms.amount != paykitReviewedAmount + }.getOrDefault(true) + } + if (!incomingPaymentRequest.acceptsPaymentAmount( + _sendUiState.value.amount, + _sendUiState.value.paykitMethod(), + paykitPaymentTerms, + Clock.System.now() + ) + ) { + return true + } + return _sendUiState.value.payMethod == SendMethod.LIGHTNING && + _sendUiState.value.decodedInvoice?.let { !incomingPaymentRequest.acceptsLightningInvoice(it) } == true } private suspend fun PaykitPaymentRequest.acceptsLightningInvoice(invoice: LightningInvoice): Boolean = withContext(bgDispatcher) { val amountMsats = runCatching { Bolt11Invoice.fromStr(invoice.bolt11).amountMilliSatoshis() } .getOrElse { return@withContext false } - acceptsLightningInvoiceAmountMsats(amountMsats) + acceptsLightningInvoiceAmountMsats(amountMsats, paykitPaymentTerms, Clock.System.now()) } private suspend fun validateIncomingPaymentRequest( contactPaymentContext: ContactPaymentContext?, ): Boolean { - val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - if ( - (_sendUiState.value.isPaymentRequest && incomingPaymentRequest == null) || - hasMismatchedIncomingPaymentRequest(contactPaymentContext) + if (!_sendUiState.value.paykitUsesUsdt && + contactPaymentContext?.incomingPaymentRequest?.let { paykitUsdtPayments.hasBinding(it.id) } == true ) { rejectMismatchedPaymentRequest() return false } + val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + val state = _sendUiState.value + val missingRequest = state.isPaymentRequest && incomingPaymentRequest == null + val invoiceAmountMismatch = state.fixedLightningAmount?.let { it != state.amount } == true + if (missingRequest || invoiceAmountMismatch || hasMismatchedIncomingPaymentRequest(contactPaymentContext)) { + rejectMismatchedPaymentRequest() + return false + } if (incomingPaymentRequest != null && !paykitPaymentRequestRepo.isPending(incomingPaymentRequest)) { toast(PaykitPaymentRequestError.RequestUnavailable) hideSheet() @@ -5370,6 +6016,11 @@ class AppViewModel @Inject constructor( fun onClickActivityDetail() { val details = _transactionSheet.value + details.usdtTransferId?.let { + hideNewTransactionSheet() + navigateToUsdtActivity(it) + return + } details.activityId?.let { hideNewTransactionSheet() mainScreenEffect( @@ -5446,7 +6097,7 @@ class AppViewModel @Inject constructor( sats = amount, speed = _sendUiState.value.speed, utxosToSpend = _sendUiState.value.selectedUtxos, - isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && + isMaxAmount = activeIncomingPaymentRequest() == null && _sendUiState.value.payMethod == SendMethod.ONCHAIN && amount == walletRepo.balanceState.value.maxSendOnchainSats, tags = tags, requestId = requestId, @@ -5497,6 +6148,20 @@ class AppViewModel @Inject constructor( fun resetQuickPay() = _quickPayData.update { null } + fun navigateToUsdtDeposits() { + viewModelScope.launch { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.UsdtDeposits)) + } + } + + fun navigateToUsdtActivity(transferId: String) { + viewModelScope.launch { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.UsdtActivity(transferId))) + } + } + fun navigateToActivity(activityRawId: String, walletId: String? = null) { viewModelScope.launch { hideSheet() @@ -5683,6 +6348,9 @@ class AppViewModel @Inject constructor( ) { sendStateGeneration++ addressValidationJob?.cancel() + paykitReviewedAmount = null + paykitAmountConversion = PaykitAmountConversion() + paykitPaymentTerms = null val speed = settingsStore.data.first().defaultTransactionSpeed val rates = let { // Refresh blocktank info to get latest fee rates @@ -5709,6 +6377,7 @@ class AppViewModel @Inject constructor( isSubscriptionPayment = isSubscriptionPayment, isInitialSubscriptionPayment = isInitialSubscriptionPayment, initialSubscriptionPaymentAutoStartPending = isInitialSubscriptionPayment, + requiresAmountReview = activeIncomingPaymentRequest()?.pricing?.conversion != null, incomingPaymentRequestId = incomingPaymentRequestId, selectedTags = selectedTags, ) @@ -6073,11 +6742,20 @@ class AppViewModel @Inject constructor( } // endregion + private fun isUsdtPaymentInput(input: String): Boolean = + input.startsWith("ethereum:", ignoreCase = true) || + (input.length == USDT_ADDRESS_LENGTH && input.startsWith("0x", ignoreCase = true)) + suspend fun canDecodeClipboard(text: String): Boolean = withContext(bgDispatcher) { - SamRockSetupRequest.isProtocolUrl(text) || runCatching { coreService.decode(text) }.isSuccess + when { + isUsdtPaymentInput(text.trim()) -> + isUsdtEnabled.value && !hasActiveUsdtSend() && parseUsdtPaymentRequest(text.trim()).isSuccess + else -> SamRockSetupRequest.isProtocolUrl(text) || runSuspendCatching { coreService.decode(text) }.isSuccess + } } fun onClipboardAutoRead(data: String) { + if (isUsdtPaymentInput(data.trim()) && (!isUsdtEnabled.value || hasActiveUsdtSend())) return viewModelScope.launch { mainScreenEffect(MainScreenEffect.ProcessClipboardAutoRead(data)) } @@ -6147,12 +6825,8 @@ class AppViewModel @Inject constructor( signedTx: HwFundingSignedTx? = null, ): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } - if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - if (incomingPaymentRequest != null && - (identity == null || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) - ) return false - if (incomingPaymentRequest != null && signedTx == null) return false + if (!validateHardwarePaymentRequest(incomingPaymentRequest, requestId, identity, signedTx)) return false // A retry of the original signed transaction skips this hook in HwSendViewModel. // A new Shop payment must consult the durable paymentStarted guard again. if (incomingPaymentRequest == null && isPreparedContactPayment(contactPaymentContext)) return true @@ -6170,6 +6844,7 @@ class AppViewModel @Inject constructor( paykitPaymentProofRepo.markOnchainPaymentStarted( requireNotNull(incomingPaymentRequest), address, + _sendUiState.value.amount, walletId ?: WalletScope.default, contactPaymentContext.privatePaymentContext?.paymentListVersion, signedTx, @@ -6192,9 +6867,28 @@ class AppViewModel @Inject constructor( return incomingPaymentRequest == null || PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) } + private fun validateHardwarePaymentRequest( + request: PaykitPaymentRequest?, + requestId: PaykitPaymentRequestId?, + identity: String?, + signedTx: HwFundingSignedTx?, + ): Boolean { + if (request?.let { paykitUsdtPayments.hasBinding(it.id) } == true) { + rejectMismatchedPaymentRequest() + return false + } + if (request?.id != requestId) return false + if (request != null && + (identity == null || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) + ) { + return false + } + return request == null || signedTx != null + } + val hardwarePaymentDeadlineAt: Instant? get() = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.incomingPaymentRequest?.paymentDeadlineAt + activeContactPaymentContext?.incomingPaymentRequest?.submissionDeadline(paykitPaymentTerms) } suspend fun onHardwarePaymentDeadlineExpired( @@ -6206,8 +6900,19 @@ class AppViewModel @Inject constructor( val context = synchronized(contactPaymentContextLock) { activeContactPaymentContext } val request = context?.incomingPaymentRequest ?: return if (request.id != requestId || identity == null || walletId == null || - walletId != _sendUiState.value.hardwareWalletId || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) return - handleHardwarePaymentFailure(PaykitPaymentRequestError.RequestExpired, context, request, walletId, identity, hasAttemptedBroadcast) + walletId != _sendUiState.value.hardwareWalletId || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + ) { + return + } + handleHardwarePaymentFailure( + PaykitPaymentRequestError.RequestExpired, + context, + request, + walletId, + identity, + hasAttemptedBroadcast + ) } suspend fun authorizeHardwareContactPayment( @@ -6217,6 +6922,10 @@ class AppViewModel @Inject constructor( walletId: String? = _sendUiState.value.hardwareWalletId, ): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (contactPaymentContext?.incomingPaymentRequest?.let { paykitUsdtPayments.hasBinding(it.id) } == true) { + rejectMismatchedPaymentRequest() + return false + } if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false val request = contactPaymentContext?.incomingPaymentRequest ?: return true if (identity == null || walletId == null) return false @@ -6229,9 +6938,18 @@ class AppViewModel @Inject constructor( if (walletId != _sendUiState.value.hardwareWalletId || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) || synchronized(contactPaymentContextLock) { activeContactPaymentContext } != contactPaymentContext - ) return false + ) { + return false + } if (error == null) return true - return handleHardwarePaymentFailure(error, contactPaymentContext, request, walletId, identity, hasAttemptedBroadcast) + return handleHardwarePaymentFailure( + error, + contactPaymentContext, + request, + walletId, + identity, + hasAttemptedBroadcast + ) } private suspend fun handleHardwarePaymentFailure( @@ -6363,6 +7081,7 @@ class AppViewModel @Inject constructor( private suspend fun consumePrivatePaymentListIfNeeded(context: ContactPaymentContext?): Result { context ?: return Result.success(Unit) + if (_sendUiState.value.paykitUsesUsdt) return Result.success(Unit) val privatePaymentContext = context.privatePaymentContext ?: return Result.success(Unit) return privatePaykitRepo.consumePrivatePaymentList(context.publicKey, privatePaymentContext) } @@ -6432,11 +7151,12 @@ class AppViewModel @Inject constructor( publicKey = acceptedDueRequest.counterparty, privatePaymentContext = resolution.privatePaymentContext, incomingPaymentRequest = acceptedDueRequest, + endpoints = resolution.endpoints, isInitialSubscriptionPayment = true, ) scanJob?.join() sheetTransitionJob?.join() - if (_currentSheet.value !is Sheet.Send) { + if (_currentSheet.value !is Sheet.Send && !showUsdtEnablePrompt) { paykitPaymentRequestRepo.markPresented(acceptedDueRequest) val error = PaykitPaymentRequestError.RequestUnavailable val failure = error.toSendFailureDetails( @@ -6607,6 +7327,7 @@ class AppViewModel @Inject constructor( publicKey = request.counterparty, privatePaymentContext = resolution.privatePaymentContext, incomingPaymentRequest = request, + endpoints = resolution.endpoints, isInitialSubscriptionPayment = true, selectedTags = tags, ) @@ -6970,6 +7691,8 @@ class AppViewModel @Inject constructor( private const val BITKIT_SCHEME = "bitkit" private const val RECOVERY_MODE_DEEPLINK = "recovery-mode" + private const val USDT_ADDRESS_LENGTH = 42 + /** Max characters kept in a scan log id before truncating. */ private const val SCAN_LOG_ID_MAX_LENGTH = 24 @@ -7020,6 +7743,7 @@ data class SendUiState( val isUnified: Boolean = false, val canSwitchWallet: Boolean = false, val canSwitchFundingSource: Boolean = false, + val fundingOptions: ImmutableList = persistentListOf(), val isFundingSourceLoading: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, val selectedTags: ImmutableList = persistentListOf(), @@ -7040,6 +7764,10 @@ data class SendUiState( val contactPaymentProfile: PubkyProfile? = null, val isPaymentRequest: Boolean = false, val paymentRequestNote: String? = null, + val paykitUsesUsdt: Boolean = false, + val paykitRateUnavailable: Boolean = false, + val paykitAmount: String = "", + val usdtRecipient: String? = null, val hardwareWalletId: String? = null, val resolvedHardwarePaymentTxId: String? = null, val isRetainedHardwarePayment: Boolean = false, @@ -7048,12 +7776,24 @@ data class SendUiState( val isSubscriptionPayment: Boolean = false, val isInitialSubscriptionPayment: Boolean = false, val initialSubscriptionPaymentAutoStartPending: Boolean = false, + val requiresAmountReview: Boolean = false, val initialSubscriptionPaymentNeedsConfirmation: Boolean = false, val incomingPaymentRequestId: PaykitPaymentRequestId? = null, ) { + val canSwitchBitcoinFundingSource: Boolean + get() = fundingOptions.count { it.enabled && it.source != SendFundingSource.Usdt } > 1 + + val fixedLightningAmount: ULong? + get() = decodedInvoice?.amountSatoshis?.takeIf { + it > 0u && !paykitUsesUsdt && payMethod == SendMethod.LIGHTNING && lnurl == null + } + + val isAmountEditable: Boolean + get() = !isPaymentRequest && fixedLightningAmount == null + val shouldAutomaticallyPay: Boolean get() = isInitialSubscriptionPayment && hardwareWalletId == null && - !initialSubscriptionPaymentNeedsConfirmation + !initialSubscriptionPaymentNeedsConfirmation && !requiresAmountReview } @Immutable @@ -7074,18 +7814,29 @@ enum class SanityWarning(@StringRes val message: Int, val testTag: String) { enum class SendMethod { ONCHAIN, LIGHTNING } -private sealed interface SendFundingSource { +sealed interface SendFundingSource { data object Spending : SendFundingSource data object Savings : SendFundingSource + data object Usdt : SendFundingSource data class Hardware(val walletId: String) : SendFundingSource } +data class SendFundingOption( + val source: SendFundingSource, + val name: String? = null, + val balance: ULong = 0uL, + val enabled: Boolean, + val transportType: TransportType? = null, + val isConnected: Boolean = false, +) + data class ContactPaymentContext( val publicKey: String, val privatePaymentContext: PrivatePaykitPaymentContext? = null, val incomingPaymentRequest: PaykitPaymentRequest? = null, val isInitialSubscriptionPayment: Boolean = false, val selectedTags: ImmutableList = persistentListOf(), + val endpoints: List = emptyList(), ) private data class PaymentProofPreparation( @@ -7115,6 +7866,7 @@ sealed class SendEffect { data object NavigateToAmount : SendEffect() data object NavigateToScan : SendEffect() data object NavigateToConfirm : SendEffect() + data object NavigateToUsdtReview : SendEffect() data object NavigateToHardwareSign : SendEffect() data object NavigateToWithdrawConfirm : SendEffect() data object NavigateToWithdrawError : SendEffect() @@ -7168,6 +7920,7 @@ sealed interface SendEvent { data object CancelInitialSubscriptionPayment : SendEvent data object SpeedAndFee : SendEvent data object PaymentMethodSwitch : SendEvent + data class PaymentMethodSelect(val source: SendFundingSource) : SendEvent data class ConfirmAmountWarning(val warning: SanityWarning) : SendEvent data object EstimateMaxRoutingFee : SendEvent data object DismissAmountWarning : SendEvent diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 99e724e7a7..c87cd361ed 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -28,6 +29,8 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitError +import to.bitkit.repositories.UsdtDisableError +import to.bitkit.repositories.UsdtPreferencesRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -43,6 +46,7 @@ class SettingsViewModel @Inject constructor( private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo, private val widgetsStore: WidgetsStore, private val widgetsRepo: WidgetsRepo, + private val usdtPreferences: UsdtPreferencesRepo, ) : ViewModel() { private companion object { const val TAG = "SettingsViewModel" @@ -60,6 +64,49 @@ class SettingsViewModel @Inject constructor( fun reset() = viewModelScope.launch { settingsStore.reset() } + private val usdtDisableChecked = MutableStateFlow(false) + val canDisableUsdt = combine(usdtPreferences.canDisable, usdtDisableChecked) { canDisable, checked -> + canDisable && checked + }.asStateFlow(initialValue = false) + val isUsdtEnabled = usdtPreferences.enabled.asStateFlow(initialValue = false) + private val _isUpdatingUsdt = MutableStateFlow(false) + val isUpdatingUsdt = _isUpdatingUsdt.asStateFlow() + + fun refreshUsdtAvailability() = viewModelScope.launch { + usdtDisableChecked.value = false + if (usdtPreferences.enabled.first()) { + usdtDisableChecked.value = usdtPreferences.checkCanDisable().isSuccess + } + } + + fun setUsdtEnabled(enabled: Boolean, onSuccess: () -> Unit = {}) { + if (_isUpdatingUsdt.value) return + _isUpdatingUsdt.update { true } + viewModelScope.launch { + try { + usdtPreferences.setEnabled(enabled).onSuccess { + onSuccess() + refreshUsdtAvailability() + }.onFailure { + Logger.error("Failed to update USDT preference", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.usdt__settings_title), + description = context.getString( + if (it is UsdtDisableError) { + R.string.usdt__disable_pending + } else { + R.string.usdt__disable_unavailable + } + ), + ) + } + } finally { + _isUpdatingUsdt.update { false } + } + } + } + val hasSeenSpendingIntro = settingsStore.data.map { it.hasSeenSpendingIntro } .asStateFlow(initialValue = false) diff --git a/app/src/main/res/drawable-nodpi/tether.png b/app/src/main/res/drawable-nodpi/tether.png new file mode 100644 index 0000000000..ccff3ff97e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/tether.png differ diff --git a/app/src/main/res/drawable-nodpi/tether_circle.png b/app/src/main/res/drawable-nodpi/tether_circle.png new file mode 100644 index 0000000000..b8558cb767 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/tether_circle.png differ diff --git a/app/src/main/res/drawable-nodpi/tether_coin.png b/app/src/main/res/drawable-nodpi/tether_coin.png new file mode 100644 index 0000000000..f283be245e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/tether_coin.png differ diff --git a/app/src/main/res/drawable-nodpi/usdt_coins_received.png b/app/src/main/res/drawable-nodpi/usdt_coins_received.png new file mode 100644 index 0000000000..d1e4b67219 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/usdt_coins_received.png differ diff --git a/app/src/main/res/drawable-nodpi/usdt_network.png b/app/src/main/res/drawable-nodpi/usdt_network.png new file mode 100644 index 0000000000..49ee71061b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/usdt_network.png differ diff --git a/app/src/main/res/drawable/ic_currency_dollar.xml b/app/src/main/res/drawable/ic_currency_dollar.xml new file mode 100644 index 0000000000..c910487ad0 --- /dev/null +++ b/app/src/main/res/drawable/ic_currency_dollar.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/ic_paper_plane_tilt.xml b/app/src/main/res/drawable/ic_paper_plane_tilt.xml new file mode 100644 index 0000000000..197105f17d --- /dev/null +++ b/app/src/main/res/drawable/ic_paper_plane_tilt.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_x_circle.xml b/app/src/main/res/drawable/ic_x_circle.xml new file mode 100644 index 0000000000..b78d8ff514 --- /dev/null +++ b/app/src/main/res/drawable/ic_x_circle.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae1a35d637..70da55eedf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -662,16 +662,26 @@ A service is requesting permission to access and edit your <accent>%1$s</accent> data. <accent>%1$s</accent> is requesting permission to access and edit your <accent>%2$s</accent> data. Unknown service + " and " + Also share my USDT address + Also, you shared a watch-only Bitcoin account with Paykit. + Also, you shared a watch-only Bitcoin account and USDT address with Paykit. + Also, you shared a USDT address with Paykit. Authorization Successful You authorized with pubky <accent>%1$s</accent> and gave the service permission to access and edit your <accent>%2$s</accent> data. You authorized with pubky <accent>%1$s</accent> and gave <accent>%2$s</accent> permission to access and edit your <accent>%3$s</accent> data. Authorize Make sure you trust the service, browser, and device before authorizing with your pubky. + USDT is turned off. + Share your existing USDT address on Arbitrum One to receive earnings directly in Bitkit. The service cannot spend your funds. + Loading USDT address… + This is your existing USDT account. The service can see its balance and transaction history, but cannot spend your funds. + Your USDT address is unavailable. Please try again. %1$s server Approve To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds. Earn - <accent>EARN BITCOIN</accent>\nFROM YOUR\nCONTENT + <accent>EARN MONEY</accent>\nFROM YOUR\nCONTENT This authorization contains more than one Bitkit claim. The requested access does not match this Bitkit claim. This Pubky authorization link is invalid. @@ -1096,6 +1106,7 @@ Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere. Profile Create Profile + Accepted payments Active Cancel Cancel Subscription @@ -1137,6 +1148,7 @@ Overview Open Bitkit to review a subscription payment. Subscription Payment Due + Payment methods Payments Per Day Per Month @@ -1205,6 +1217,135 @@ Total %1$s, %2$d UTXO %1$s, %2$d UTXOs + Activity + Recipient address + Amount + Balance + Bridge needs attention + Estimated bridge cost (included) + Delivery is estimated. Bridge costs are deducted from the amount; the network fee is additional. + Bridge + Bridge reference + USDT was returned to your wallet. See details for the refunded amount. + Your payment is pending. You can track confirmation and cross-network delivery in Activity. + Delivering to destination + Allow camera access to scan USDT payment QR codes. + Confirmed + Copy Details + Delivering + Delivering USDT + Deposit amount + Amount outside deposit limits. + This deposit needs provider assistance. Contact Bitkit support with the deposit reference. + The deposit service could not verify this request. Try again. + Converted batch + Estimated deduction (USDT) + Get Deposit Address + No deposits yet. + Estimated received (USDT) + Indicative price only. Costs are deducted from the USDT received and may change before conversion. The sender pays their network gas separately. + Fees are deducted from the USDT you receive. The sender pays their network fees separately. + Cross-Network Deposits + Deposit limits + Live liquidity and network costs can further restrict these limits. Amounts outside the limits may be held. + Maximum amount is %1$s + Minimum amount is %1$s + Load more + Needs attention + Deposit details changed. Refresh the deposit history and select it again. + Send only USDT on the selected network. Orchestra delivers USDT0 to your Arbitrum One wallet. This address is reusable; pricing is set when each deposit is processed. Deposits may be held for review. + Provider status + Deposit reference + Refresh + Request Refund + Request refund + USDT address on {network} + Confirm Refund + Request refund for {amount} deposit?\n\nNetwork:\n{network}\n\nRefund address:\n{address}\n\nConfirm this address can receive USDT on {source}. Refund fees may apply. + Enter a USDT address on {network} to receive the refund. If someone else sent the funds, confirm the address with them.\n\nFor deposits sent from an exchange, don’t copy the transaction’s sending address. Ask the sender for a suitable receiving address. + Refund requested. This is not a confirmed refund; check the deposit status. + Request a refund + Refunded + Refund in progress + Your spendable balance updates when USDT arrives on Arbitrum. + TRON refunds can require Orchestra operator assistance. + Cross-network receiving is currently unavailable. Direct Arbitrum receive still works. + Unsupported deposit asset — contact support + Recipient network + Destination transaction + Send your remaining USDT and wait for pending transfers to finish before turning it off. + Unable to check your USDT balance and transfers. Try again when connected. + Enable USDT + Enable USDT to continue with this payment or request. + Enable USDT? + Enter a valid address for the selected network. + Enter a valid amount with up to 6 decimal places. + Unable to back up this payment. It will be retried when backup is available. + Not enough USDT to cover the amount and fees. + Amount too small to cover bridge costs. Try a larger amount. + Set your device date and time automatically, then try again. + USDT is currently unavailable. Please try again later. + This address uses another wallet’s smart account. Disable its smart account mode there before sending with Bitkit. + The fee quote expired. Review the payment again. + Unable to pay the transaction fee in USDT. Try a smaller amount. + Could not update USDT. Check your connection and try again. + A payment is still pending. Wait for confirmation before sending again. + Too many requests. Please try again shortly. + The USDT network rejected the request. Please try again. + The USDT service returned an invalid response. Please try again later. + This network is currently unavailable. + You can’t send USDT to your own address. + Could not access USDT wallet data. Please try again. + Could not access your USDT wallet. Reopen Bitkit and try again. + Expected amount + Failed + All fees are paid in USDT. Final fee may be lower. + From address + Hide details + Older activity is still syncing. Your balance is up to date when the connection is available. + No Thanks + Bitcoin at heart. Dollars when you need them. Enable USDT (Tether) to send and receive digital dollars with Bitkit. + <accent>DOLLARS?</accent>\nYOUR CALL + New: USDT + Maximum fee + Maximum total debit + Network + Select the network used by the recipient. Sending to an address on the wrong network can lose funds. + Pay from + Received USDT + USDT Sent + Pending + Receive + Amount + Receive (est) + Fees + Estimated Fees + Network: %1$s + Note: Regardless of the network used to receive, your USDT will be credited as Arbitrum One balance. + USDT Network + Receive Dollars + Receive USDT on Arbitrum One only. Bridge funds from other networks before sending. + Received + Receiving network + Receiving USDT + Recipient gets + Refund transaction + Replaced by another payment + Send + Your payment was not completed. Check Activity for details. + Choose the network your recipient uses. + Send USDT + Sender network + Sent + Sent USDT + USDT (Tether) + Enable USDT to share your receiving address. + Payment submitted + Your payment is being processed. You can track its status in Activity. + To address + To network {network} + Track cross-network delivery + You send Activity Address All Activity @@ -1234,6 +1375,12 @@ Failed Fee Fee (prepaid) + Earlier + This month + Today + This week + This year + Yesterday {count, plural, one {INPUT} other {INPUTS (#)}} Invoice Invoice note @@ -1268,6 +1415,7 @@ From Savings ({duration}) To Savings To Spending + Transferring Transaction ID Swipe your wallet balance to reveal it again. Wallet Balance Hidden @@ -1331,6 +1479,8 @@ The recipient rejected this Lightning invoice request. The recipient rejected this Lightning payment. Please check the invoice and try again. Payment Request + Accept payment in + Received · After expiry Amount Choose Recipient Contact @@ -1353,13 +1503,17 @@ Or Pay ₿]]> Paste Pay + Payment verified pending Your payment request is queued and will send automatically + Payment rates are unavailable or expired. Try again when online. RECIPIENT No matching saved contact with a private connection. Payment requests can only be sent to a saved contact with a private connection. Request Request Payment + Requested amount + Received · Review needed Send Payment Request Send Request Queued for delivery @@ -1375,6 +1529,7 @@ Unavailable Time The payment request is no longer available. + Received · Underpaid Waiting for payment Waiting for updated private payment details. Bitkit will retry automatically. Payment Requests diff --git a/app/src/test/java/to/bitkit/models/PaykitAmountTest.kt b/app/src/test/java/to/bitkit/models/PaykitAmountTest.kt new file mode 100644 index 0000000000..66f299f036 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PaykitAmountTest.kt @@ -0,0 +1,81 @@ +package to.bitkit.models + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PaykitAmountTest { + @Test + fun `editing conversion preserves the original across repeated toggles and resets on edits`() { + val converter = PaykitAmountConversion() + val rate = PaykitExchangeRate("65432.10", now) + val original = PaykitAmount.parse(PaykitAsset.USD, "10") + var amount = original + repeat(5) { + amount = converter.convert(amount, PaykitAsset.BTC, rate, now) + assertEquals(15_284uL, amount.atomic) + amount = converter.convert(amount, PaykitAsset.USD, rate, now) + assertEquals(original, amount) + } + val edited = PaykitAmount.parse(PaykitAsset.BTC, "0.001") + amount = converter.convert(edited, PaykitAsset.USD, rate, now) + assertEquals("65.44", amount.value) + assertEquals(edited, converter.convert(amount, PaykitAsset.BTC, rate, now)) + assertEquals(0uL, converter.convert(null, PaykitAsset.USD, null, now).atomic) + } + + @Test + fun `amount entry accepts a trailing decimal separator without weakening protocol parsing`() { + assertEquals("5", PaykitAmount.parseInput(PaykitAsset.USDT, "5.").value) + for (input in listOf(".", "5..", "0.", "1e2")) { + assertFailsWith { PaykitAmount.parseInput(PaykitAsset.USDT, input) } + } + assertFailsWith { PaykitAmount.parse(PaykitAsset.USDT, "5.") } + } + + private val now = 1_800_000_000_000L + private val rate = PaykitExchangeRate("100000", now) + + @Test + fun `conversion preserves value and rounds payment atomic units upward`() { + data class Case(val source: PaykitAsset, val value: String, val target: PaykitAsset, val expected: String) + val cases = listOf( + Case(PaykitAsset.USD, "5", PaykitAsset.USDT, "5"), + Case(PaykitAsset.USDT, "5.000001", PaykitAsset.USD, "5.01"), + Case(PaykitAsset.BTC, "0.00000001", PaykitAsset.USDT, "0.001"), + Case(PaykitAsset.USD, "5", PaykitAsset.BTC, "0.00005"), + Case(PaykitAsset.USDT, "0.000001", PaykitAsset.BTC, "0.00000001"), + ) + cases.forEach { + assertEquals(it.expected, PaykitAmount.parse(it.source, it.value).convertedTo(it.target, rate, now).value) + } + } + + @Test + fun `stale missing and future rates block only bitcoin conversions`() { + val amount = PaykitAmount.parse(PaykitAsset.USD, "5") + listOf( + null, + rate.copy(timestampMillis = now - 600001), + rate.copy(timestampMillis = now + 1), + rate.copy(price = "0"), + rate.copy(price = "NaN") + ).forEach { invalid -> + assertFailsWith { amount.convertedTo(PaykitAsset.BTC, invalid, now) } + assertEquals("5", amount.convertedTo(PaykitAsset.USDT, invalid, now).value) + } + assertEquals( + "0.00005", + amount.convertedTo(PaykitAsset.BTC, rate.copy(timestampMillis = now - 600000), now).value + ) + } + + @Test + fun `amount parsing enforces positive exact decimals and atomic bounds`() { + listOf("", "0", "-1", "+1", "1e2", "1,2", ".5", "5.", "0.0000001", "18446744073710").forEach { + assertFailsWith { PaykitAmount.parse(PaykitAsset.USDT, it) } + } + assertEquals("1.23", PaykitAmount.parse(PaykitAsset.USDT, "01.23000000").value) + assertEquals(ULong.MAX_VALUE, PaykitAmount.parse(PaykitAsset.USDT, "18446744073709.551615").atomic) + } +} diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index b3da7c259e..8758e8a2d0 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -51,6 +51,36 @@ class PaykitPaymentStateBackupTest { assertContains(codec.encodeToString(decoded), "\"activeOnchainAttempt\"") } + @Test + fun `wallet envelope preserves core recovery data`() { + val fixture = """ + {"version":1,"createdAt":1,"transfers":[], + "usdtWallet":"{\"identity\":\"42161:wallet\",\"transfers\":[]}"} + """.trimIndent() + val decoded = Json.decodeFromString(fixture) + assertEquals("""{"identity":"42161:wallet","transfers":[]}""", decoded.usdtWallet) + assertEquals(decoded, Json.decodeFromString(Json.encodeToString(decoded))) + val withoutUsdt = """{"version":1,"createdAt":1,"transfers":[]}""" + assertEquals(null, Json.decodeFromString(withoutUsdt).usdtWallet) + } + + @Test + fun `usdt backup preserves shared wire format`() { + val fixture = usdtBackupFixture + val backup = Json.decodeFromString(fixture) + val attempt = backup.attempts.single() + val receipt = backup.receipts.single().restored() + assertTrue(attempt.paymentStarted) + assertEquals("bitkit", attempt.binding?.paymentAppId) + assertEquals("quote", attempt.binding?.conversionQuoteId) + assertEquals("2026-10-01T00:00:00.125Z", attempt.billingPeriod?.sdkValue?.startsAt) + assertEquals(50_000uL, receipt.amount.atomic) + assertEquals(attempt.requestId, receipt.requestId) + val rebuilt = PaykitUsdtStateBackup(listOf(attempt), listOf(PaykitUsdtStateBackup.Receipt(receipt))) + val json = Json { encodeDefaults = true } + assertEquals(json.parseToJsonElement(fixture), json.parseToJsonElement(json.encodeToString(rebuilt))) + } + @Test fun `payment backup accepts shared wire format and retains pending payment`() { WalletScope.pushTestOverride("wallet0").use { @@ -102,4 +132,58 @@ class PaykitPaymentStateBackupTest { assertFailsWith { unknownKind.pendingProofs.single().restored() } } } + private val usdtBackupFixture = """ + { + "attempts": [ + { + "quoteId": "operation", + "wallet": "wallet", + "identity": "alice", + "contact": "bob", + "requestId": { + "paymentRequestId": "request", + "counterparty": "bob", + "billingPeriodStartsAt": "2026-10-01T00:00:00.125Z" + }, + "binding": { + "payer": "alice", + "payee": "bob", + "paymentAppId": "bitkit", + "paymentRequestId": "request", + "paymentReference": "invoice", + "paymentEndpointIdentifier": "usdt-arbitrum-address", + "periodStartsAt": "2026-10-01T00:00:00.125Z", + "periodEndsAt": "2026-11-01T00:00:00.125Z", + "conversionQuoteId": "quote" + }, + "billingPeriod": { + "startsAt": "2026-10-01T00:00:00.125Z", + "endsAt": "2026-11-01T00:00:00.125Z" + }, + "proof": null, + "proofQueued": false, + "paymentStarted": true + } + ], + "receipts": [ + { + "wallet": "wallet", + "identity": "alice", + "requestId": { + "paymentRequestId": "request", + "counterparty": "bob", + "billingPeriodStartsAt": "2026-10-01T00:00:00.125Z" + }, + "paymentId": "42161:transaction:2", + "proofEventId": "proof", + "verified": true, + "transferId": "transfer", + "amountAtomic": 50000, + "receivedAtMillis": 1790812801000, + "underpaid": false, + "afterExpiry": false + } + ] + } + """.trimIndent() } diff --git a/app/src/test/java/to/bitkit/models/PaykitRequestPricingTest.kt b/app/src/test/java/to/bitkit/models/PaykitRequestPricingTest.kt new file mode 100644 index 0000000000..eee91c482c --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PaykitRequestPricingTest.kt @@ -0,0 +1,175 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.models + +import com.synonym.paykit.ConversionRate +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentConversionQuoteRecord +import com.synonym.paykit.PaymentDeadline +import org.junit.Test +import to.bitkit.repositories.MethodId +import to.bitkit.repositories.PaykitBillingPeriod +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class PaykitRequestPricingTest { + private val now = Instant.fromEpochSeconds(1_800_000_000) + + @Test fun `fixed rates determine exact amounts without market rates`() { + val pricing = PaykitRequestPricing( + PaymentConversion.Fixed( + listOf( + ConversionRate("btc", "0.000012345"), + ConversionRate("usdt", "1"), + ) + ) + ) + val requested = PaykitAmount.parse(PaykitAsset.USD, "0.05") + assertEquals(62uL, pricing.payment(requested, "btc-bitcoin-p2wpkh", null, now).amount.atomic) + assertEquals(50_000uL, pricing.payment(requested, "usdt-arbitrum-address", null, now).amount.atomic) + assertEquals(requested, pricing.payment(requested, "usd-bank-account", null, now).amount) + assertFails { pricing.payment(requested, "usdt-arbitrum-address", null, now, "unknown") } + } + + @Test fun `fixed pricing selects the rail before the asset including same asset payments`() { + val requested = PaykitAmount.parse(PaykitAsset.BTC, "1") + val rates = listOf(ConversionRate("btc", "2"), ConversionRate("btc-lightning", "0.5")) + for (values in listOf(rates, rates.reversed())) { + val pricing = PaykitRequestPricing(PaymentConversion.Fixed(values)) + assertEquals("0.5", pricing.payment(requested, "btc-lightning-bolt11", null, now).amount.value) + assertEquals("0.5", pricing.payment(requested, "btc-lightning-lnurl", null, now).amount.value) + assertEquals("2", pricing.payment(requested, "btc-bitcoin-p2wpkh", null, now).amount.value) + } + val pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(rates[1]))) + assertEquals(requested, pricing.payment(requested, "btc-bitcoin-p2wpkh", null, now).amount) + } + + @Test fun `payment market value is independent of the requested value`() { + val pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.001")))) + val requested = PaykitAmount.parse(PaykitAsset.USD, "1000") + val payment = pricing.payment(requested, "btc-bitcoin-p2wpkh", null, now) + assertEquals("1", payment.amount.value) + val valuation = payment.amount.convertedTo( + PaykitAsset.USD, + PaykitExchangeRate("2000", now.toEpochMilliseconds()), + now.toEpochMilliseconds() + ) + assertEquals("2000", valuation.value) + assertEquals("1000", requested.value) + } + + @Test fun `same asset recurring payments use parity and optional quote validity`() { + val requested = PaykitAmount.parse(PaykitAsset.BTC, "1") + val period = PaykitBillingPeriod(now, now + 86400.seconds) + val quote = PaymentConversionQuoteRecord( + "quote", + period.sdkValue, + listOf(ConversionRate("usdt", "100000")), + now.toString(), + (now + 60.seconds).toString(), + null + ) + val pricing = PaykitRequestPricing(PaymentConversion.PerPeriod, quotes = listOf(quote)) + val unquoted = pricing.payment(requested, "btc-lightning-bolt11", period, now) + assertEquals(requested, unquoted.amount) + assertEquals(null, unquoted.quoteId) + val quoted = pricing.payment(requested, "btc-lightning-bolt11", period, now, "quote") + assertEquals(requested, quoted.amount) + assertEquals("quote", quoted.quoteId) + assertFalse(quoted.isValid(now - 1.seconds)) + assertTrue(quoted.isValid(now + 60.seconds)) + assertFalse(quoted.isValid(now + 61.seconds)) + assertFails { pricing.payment(requested, "btc-lightning-bolt11", period, now, "unknown") } + } + + @Test fun `subscriptions keep one payment currency across billing periods`() { + val bitcoin = MethodId.P2wpkh.rawValue + val lightning = MethodId.Bolt11.rawValue + val usdt = MethodId.UsdtArbitrum.rawValue + val available = listOf(bitcoin, lightning, usdt) + for ((asset, endpoints) in listOf( + PaykitAsset.BTC to listOf(bitcoin, lightning), + PaykitAsset.USD to listOf(usdt), + )) { + val selected = PaykitRequestPricing.subscriptionEndpoints(asset, available) + assertEquals(endpoints, selected) + assertTrue(PaykitRequestPricing.subscriptionEndpoints(asset, available - endpoints.toSet()).isEmpty()) + val rates = PaykitRequestPricing.rates(asset, selected, null, now) + val pricing = PaykitRequestPricing(rates.takeIf { it.isNotEmpty() }?.let(PaymentConversion::Fixed)) + val requested = PaykitAmount.parse(asset, "5") + val period = PaykitBillingPeriod(now + (31 * 86400).seconds, now + (62 * 86400).seconds) + val payment = pricing.payment(requested, endpoints.first(), period, period.startsAt) + assertEquals("5", payment.amount.value) + assertEquals(null, payment.quoteId) + assertEquals(null, payment.expiresAt) + assertTrue(payment.isValid(period.endsAt)) + } + assertTrue(PaykitRequestPricing.subscriptionEndpoints(PaykitAsset.USDT, available).isEmpty()) + } + + @Test fun `missing terms or rate never implicitly enable conversion`() { + val requested = PaykitAmount.parse(PaykitAsset.USD, "5") + for (pricing in listOf( + PaykitRequestPricing(), + PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.00001")))) + )) { + assertFails { pricing.payment(requested, "usdt-arbitrum-address", null, now) } + assertEquals(requested, pricing.payment(requested, "usd-bank-account", null, now).amount) + } + } + + @Test fun `quoted arithmetic rejects overflow and rounds only once`() { + val requested = PaykitAmount.parse(PaykitAsset.BTC, "0.00000001") + assertEquals(1235uL, requested.quotedTo(PaykitAsset.USDT, "123456.789012345678").atomic) + assertFails { PaykitAmount(PaykitAsset.USDT, ULong.MAX_VALUE).quotedTo(PaykitAsset.USDT, "2") } + for (rate in listOf("0", "-1", "NaN", "1e2", "0.123456789012345678901234567890123456789")) { + assertFails { requested.quotedTo(PaykitAsset.USDT, rate) } + } + } + + @Test fun `rates are required only when issuing bitcoin conversions`() { + val usdt = MethodId.UsdtArbitrum.rawValue + assertEquals( + listOf(ConversionRate("usdt", "1")), + PaykitRequestPricing.rates(PaykitAsset.USD, listOf(usdt), null, now) + ) + assertFails { PaykitRequestPricing.rates(PaykitAsset.BTC, listOf(usdt), null, now) } + val stale = PaykitExchangeRate("100000", now.toEpochMilliseconds() - 601000) + assertFails { PaykitRequestPricing.rates(PaykitAsset.BTC, listOf(usdt), stale, now) } + } + + @Test fun `recurring payments keep their chosen quote and inclusive deadline`() { + val period = PaykitBillingPeriod(now, now + 86400.seconds) + val first = PaymentConversionQuoteRecord( + "first", + period.sdkValue, + listOf(ConversionRate("usdt", "100000")), + now.toString(), + (now + 60.seconds).toString(), + null + ) + val later = first.copy(eventId = "later", rates = listOf(ConversionRate("usdt", "110000"))) + val pricing = PaykitRequestPricing( + PaymentConversion.PerPeriod, + PaymentDeadline.PeriodStart(30u), + listOf(first, later) + ) + val requested = PaykitAmount.parse(PaykitAsset.BTC, "0.00001") + val selected = pricing.payment(requested, "usdt-arbitrum-address", period, now) + assertEquals("1.1", selected.amount.value) + assertEquals("later", selected.quoteId) + val pinned = pricing.payment(requested, "usdt-arbitrum-address", period, now, "first") + assertEquals("1", pinned.amount.value) + assertTrue(pinned.isValid(now + 30.seconds)) + assertFalse(pinned.isValid(now + 31.seconds)) + assertFalse(pinned.isValid(now - 1.seconds)) + assertEquals(pinned, pricing.payment(requested, "usdt-arbitrum-address", period, now + 600.seconds, "first")) + assertFails { pricing.payment(requested, "usdt-arbitrum-address", period, now, "missing") } + assertFails { pricing.payment(requested, "usdt-arbitrum-address", null, now) } + } +} diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index be84f8f4d1..0339f19820 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,5 +1,6 @@ package to.bitkit.models +import kotlinx.serialization.json.Json import to.bitkit.models.PubkyAuthClaim.Item import java.net.URLEncoder import kotlin.test.Test @@ -12,6 +13,19 @@ import kotlin.test.assertTrue class PubkyAuthRequestTest { + @Test + fun `optional USDT omission preserves delegated Paykit authority`() { + val claim = requireNotNull(PubkyAuthClaim.fromWireValue("paykit-access-v1.usdt-address-v1")) + val payload = PubkyAuthClaimCodec.encode(claim, "{}".encodeToByteArray(), 3uL, ByteArray(32) { 11 }) + val actual = Json.parseToJsonElement(payload.decodeToString()) + val expected = Json.parseToJsonElement( + """{"paykit_access":{"key_generation":3,"secret":"CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws"}}""" + ) + assertEquals(expected, actual) + assertTrue(claim.sharesUsdt) + assertFalse(claim.sharesBitcoin) + } + @Test fun `parse preserves each companion selection and received item order`() { val expected = mapOf( diff --git a/app/src/test/java/to/bitkit/models/TrezorPublicKeyTest.kt b/app/src/test/java/to/bitkit/models/TrezorPublicKeyTest.kt new file mode 100644 index 0000000000..d95984e599 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/TrezorPublicKeyTest.kt @@ -0,0 +1,96 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.TrezorPublicKeyResponse +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Fixtures are the BIP-44/49/84/86 test vectors for the "abandon ... about" mnemonic. */ +class TrezorPublicKeyTest { + + companion object Fixtures { + private const val LEGACY_XPUB = + "xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R" + + "5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj" + private const val NESTED_YPUB = + "ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azL" + + "DWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP" + private const val NESTED_XPUB = + "xpub6C6nQwHaWbSrzs5tZ1q7m5R9cPK9eYpNMFesiXsYrgc1P8bvLLA" + + "et9JfHjYXKjToD8cBRswJXXbbFpXgwsswVPAZzKMa1jUp2kVkGVUaJa7" + private const val NATIVE_ZPUB = + "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXN" + + "fE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs" + private const val NATIVE_XPUB = + "xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3" + + "XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V" + private const val TAPROOT_XPUB = + "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2a" + + "fYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ" + private const val TAPROOT_DESCRIPTOR = "tr([73c5da0a/86'/0'/0']$TAPROOT_XPUB/<0;1>/*)" + } + + @Test + fun `segwit accounts keep the firmware slip132 key`() { + val nested = response(xpub = NESTED_XPUB, xpubSegwit = NESTED_YPUB, path = "m/49'/0'/0'") + val native = response(xpub = NATIVE_XPUB, xpubSegwit = NATIVE_ZPUB, path = "m/84'/0'/0'") + + assertEquals(NESTED_YPUB, nested.storedAccountKey(AddressType.P2SH)) + assertEquals(NATIVE_ZPUB, native.storedAccountKey(AddressType.P2WPKH)) + } + + @Test + fun `legacy accounts keep the xpub`() { + val legacy = response(xpub = LEGACY_XPUB, xpubSegwit = null, path = "m/44'/0'/0'") + + assertEquals(LEGACY_XPUB, legacy.storedAccountKey(AddressType.P2PKH)) + } + + @Test + fun `taproot accounts never store the descriptor`() { + val taproot = response( + xpub = TAPROOT_XPUB, + xpubSegwit = TAPROOT_DESCRIPTOR, + descriptor = TAPROOT_DESCRIPTOR, + path = "m/86'/0'/0'", + ) + + assertEquals(TAPROOT_XPUB, taproot.storedAccountKey(AddressType.P2TR)) + } + + @Test + fun `segwit accounts fall back to the xpub without a segwit form`() { + val native = response(xpub = NATIVE_XPUB, xpubSegwit = null, path = "m/84'/0'/0'") + + assertEquals(NATIVE_XPUB, native.storedAccountKey(AddressType.P2WPKH)) + } + + @Test + fun `address type follows the derivation path purpose`() { + assertEquals(AddressType.P2PKH, addressTypeForDerivationPath("m/44'/0'/0'")) + assertEquals(AddressType.P2SH, addressTypeForDerivationPath("m/49'/1'/0'")) + assertEquals(AddressType.P2WPKH, addressTypeForDerivationPath("m/84'/1'/0'")) + assertEquals(AddressType.P2TR, addressTypeForDerivationPath("m/86h/0h/0h")) + assertNull(addressTypeForDerivationPath("m/45'/0'/0'")) + assertNull(addressTypeForDerivationPath("84'/0'/0'")) + } + + private fun response( + xpub: String, + xpubSegwit: String?, + path: String, + descriptor: String? = null, + ) = TrezorPublicKeyResponse( + xpub = xpub, + xpubSegwit = xpubSegwit, + descriptor = descriptor, + displayablePublicKey = xpubSegwit ?: xpub, + path = path, + publicKey = "02", + chainCode = "00", + fingerprint = 0u, + depth = 3u, + rootFingerprint = 0x73c5da0au, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 685da84e68..28e5da5884 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.flow.first import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -55,6 +56,7 @@ import to.bitkit.models.BackupItemStatus import to.bitkit.models.KnownDevice import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.PaykitPaymentStateBackup +import to.bitkit.models.PaykitUsdtStateBackup import to.bitkit.models.TransportType import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -96,6 +98,8 @@ class BackupRepoTest : BaseUnitTest() { private val privatePaykitRepo = mock() private val privatePaykitAddressReservationRepo = mock() private val paykitPaymentProofRepo = mock() + private val usdtRepo = mock() + private val paykitUsdtPaymentRepo = mock() private val paykitPaymentProofStore = mock() private val paykitPaymentRequestRepo = mock() private val paykitPresentationStore = mock() @@ -155,6 +159,7 @@ class BackupRepoTest : BaseUnitTest() { whenever { vssBackupClient.putObject(any(), any()) } .thenReturn(Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1))) whenever { vssBackupClient.setupWithRetry(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever { vssBackupClient.setup(any()) }.thenReturn(Result.success(Unit)) whenever { vssBackupClientLdk.setup() }.thenReturn(Result.success(Unit)) whenever { transferDao.getAll() }.thenReturn(emptyList()) whenever { privatePaykitRepo.restoreBackup(anyOrNull()) }.thenReturn(Result.success(Unit)) @@ -162,6 +167,11 @@ class BackupRepoTest : BaseUnitTest() { whenever(privatePaykitRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever { paykitPaymentProofRepo.backupSnapshot() }.thenReturn(emptyList()) + whenever { paykitUsdtPaymentRepo.backupSnapshot() }.thenReturn( + PaykitUsdtStateBackup(emptyList(), emptyList()) + ) + whenever(usdtRepo.state).thenReturn(MutableStateFlow(UsdtWalletState())) + whenever(paykitUsdtPaymentRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paykitPresentationStore.backupSnapshot()).thenReturn(emptyMap()) whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitAddressReservationRepo.backupSnapshot() }.thenReturn(Result.success(null)) @@ -684,6 +694,60 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } + @Test + fun `payment backup waits for earlier uploads and propagates failure`() = test { + val release = CompletableDeferred() + val uploads = mutableListOf() + var fail = false + whenever(usdtRepo.backupSnapshot()).thenReturn("older") + whenever { vssBackupClient.putObject(eq(BackupCategory.WALLET.name), any()) }.doSuspendableAnswer { + val payload = json.decodeFromString(it.getArgument(1).decodeToString()) + if (payload.usdtWallet == "older") release.await() + uploads += payload.usdtWallet + if (fail) { + Result.failure(BackupRepoTestError("offline")) + } else { + Result.success(VssItem(key = BackupCategory.WALLET.name, value = byteArrayOf(), version = 1)) + } + } + val background = async { sut.triggerBackup(BackupCategory.WALLET) } + runCurrent() + val payment = async { sut.persistWalletBackup("signed") } + runCurrent() + assertTrue(uploads.isEmpty()) + release.complete(Unit) + background.await().getOrThrow() + payment.await().getOrThrow() + assertEquals(listOf("older", "signed"), uploads.toList()) + fail = true + assertTrue(sut.persistWalletBackup("signed").isFailure) + } + + @Test + fun `payment backup retries setup before uploading`() = test { + whenever { vssBackupClient.setup(any()) } + .thenReturn(Result.failure(BackupRepoTestError("offline"))) + .thenReturn(Result.success(Unit)) + + assertTrue(sut.persistWalletBackup("signed").isFailure) + verify(vssBackupClient, never()).putObject(any(), any()) + + sut.persistWalletBackup("signed").getOrThrow() + verify(vssBackupClient, times(2)).setup(any()) + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `core restore failure retains wallet backup`() = test { + stubWalletBackup(usdtWallet = "signed") + whenever { usdtRepo.restoreBackup("signed") }.thenReturn(Result.failure(BackupRepoTestError("storage"))) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + val retained = argumentCaptor() + verify(keychain, times(2)).upsertString(eq(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name), retained.capture()) + assertEquals("signed", json.decodeFromString(retained.lastValue).usdtWallet) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `start observing is skipped while wiping`() = test { sut.setWiping(true) @@ -1646,6 +1710,7 @@ class BackupRepoTest : BaseUnitTest() { watchOnlyAccounts: List? = null, watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? = null, paykitPaymentState: PaykitPaymentStateBackup? = null, + usdtWallet: String? = null, ) { val walletBackup = WalletBackupV1( createdAt = 123, @@ -1655,6 +1720,7 @@ class BackupRepoTest : BaseUnitTest() { watchOnlyAccounts = watchOnlyAccounts, watchOnlyAccountAllocationState = watchOnlyAccountAllocationState, paykitPaymentState = paykitPaymentState, + usdtWallet = usdtWallet, ) whenever { vssBackupClient.getObject(BackupCategory.WALLET.name) } .thenReturn( @@ -1722,6 +1788,8 @@ class BackupRepoTest : BaseUnitTest() { privatePaykitRepo = Provider { privatePaykitRepo }, privatePaykitAddressReservationRepo = Provider { privatePaykitAddressReservationRepo }, paykitPaymentProofRepo = Provider { paykitPaymentProofRepo }, + paykitUsdtPaymentRepo = Provider { paykitUsdtPaymentRepo }, + usdtRepo = Provider { usdtRepo }, paykitPaymentProofStore = paykitPaymentProofStore, paykitPaymentRequestRepo = Provider { paykitPaymentRequestRepo }, paykitPresentationStore = paykitPresentationStore, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 1982c67471..de689004ea 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3,8 +3,11 @@ package to.bitkit.repositories import android.content.Context import com.synonym.bitkitcore.BroadcastException import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.ConversionRate import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentConversionQuoteRecord import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -57,7 +60,10 @@ import to.bitkit.models.HwConnectedDevice import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset import to.bitkit.models.PaykitPaymentStateBackup +import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.services.PaykitSdkService @@ -74,6 +80,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -166,7 +173,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .doSuspendableAnswer { assertEquals(txid, PaykitPaymentProofStore(diskKeychain).load().single().proofData) delay(5.seconds) @@ -174,7 +181,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } val repo = paymentProofRepo(diskStore) repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() val started = testScheduler.currentTime assertTrue( repo.completeOnchainPayment( @@ -200,9 +207,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(store.save(any())).doSuspendableAnswer { throw AppError("disk unavailable") } - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .doSuspendableAnswer { delay(5.seconds) throw AppError("delivery unavailable") @@ -237,7 +244,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() assertTrue( repo.completeOnchainPayment( request, @@ -252,14 +259,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.identityStatus()).thenReturn(status) paymentProofRepo().reconcile() assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } storedProofs = emptyList() whenever(paykitSdkService.identityStatus()) .thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) repo.reconcile() assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -267,7 +274,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs = listOf(readyLightningProof(PAYMENT_REQUEST_ID).copy(paymentAppId = "")) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) paymentProofRepo().reconcile() - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertEquals("", storedProofs.single().paymentAppId) assertTrue(storedProofs.single().paymentStarted) } @@ -363,13 +370,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() var admitted = attempts.admit( walletId = WalletScope.default, requestId = request.id, orderId = null, - address = ONCHAIN_ADDRESS, amountSats = request.amountSats, isMaxAmount = false, + address = ONCHAIN_ADDRESS, amountSats = request.amount.atomic, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), payerIdentity = LOCAL_IDENTITY, beforeSendAttempt = { repo.markOnchainPaymentStarted( request, ONCHAIN_ADDRESS, + request.amount.atomic, privatePaymentListVersion = 7uL, previousPrivatePaymentListVersion = 6uL, ).getOrThrow() @@ -378,9 +386,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(admitted.preparationPending) assertTrue(storedProofs.single().paymentStarted) assertFalse(attempts.releaseInterruptedShopPreparation { error("live preparation must not be cleared") }) - admitted = attempts.retainPreparedReceipt(admitted.attemptId, 0, - OnchainPreparedReceipt("ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), - ONCHAIN_ADDRESS, request.amountSats), false) + admitted = attempts.retainPreparedReceipt( + admitted.attemptId, 0, + OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), + ONCHAIN_ADDRESS, request.amount.atomic + ), + false + ) assertFalse(attempts.releaseInterruptedShopPreparation { error("live prepared receipt must not be cleared") }) val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) whenever(lightningRepo.currentOnchainSendAttempt()).doSuspendableAnswer { reopened.current() } @@ -418,7 +431,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() val result = repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit") @@ -437,7 +450,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() - val result = repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS) + val result = repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic) assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) assertEquals(1, storedProofs.count { it.paymentStarted }) @@ -469,7 +482,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(record) val firstRepo = paymentProofRepo() @@ -492,6 +505,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + conversionQuoteId = isNull(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -512,7 +526,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenReturn(record) val lightning = readyLightningProof(PAYMENT_REQUEST_ID) val onchain = lightning.copy( @@ -521,7 +535,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentIdentifier = "ab".repeat(32), proofData = "ab".repeat(32), onchainAddress = ONCHAIN_ADDRESS, - onchainAmountSats = paymentRequest(MethodId.P2wpkh.rawValue).amountSats, + onchainAmountSats = paymentRequest(MethodId.P2wpkh.rawValue).amount.atomic, ) listOf(lightning, onchain).forEach { proof -> @@ -542,6 +556,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = eq(proof.paymentEndpointIdentifier), proofJson = any(), billingPeriod = isNull(), + conversionQuoteId = isNull(), ) } } @@ -559,7 +574,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(paymentRequestRecord(paymentRequestId = secondPaymentRequestId)) @@ -573,6 +588,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = isNull(), + conversionQuoteId = isNull(), ) assertEquals(listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), paymentRequestIdCaptor.allValues) assertEquals(listOf(PAYMENT_REQUEST_ID), storedProofs.map { it.requestId.paymentRequestId }) @@ -593,7 +609,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val firstRepo = paymentProofRepo() firstRepo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() @@ -611,6 +629,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + conversionQuoteId = isNull(), ) assertEquals( """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", @@ -624,14 +643,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -645,7 +666,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -668,7 +689,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -693,7 +714,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -707,7 +728,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() @@ -734,7 +757,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { restartedRepo.reconcile() assertTrue(storedProofs.isEmpty()) } - verify(paykitSdkService, times(errors.size)).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + times(errors.size) + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -767,11 +793,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() assertTrue(storedProofs.single().paymentStarted) repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) repo.reconcile() @@ -785,6 +813,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { endpointCaptor.capture(), proofCaptor.capture(), isNull(), + conversionQuoteId = isNull(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -800,7 +829,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() repo.cancelPreparation(request) assertTrue(storedProofs.single().paymentStarted) @@ -812,7 +841,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() repo.failOnchainPayment(request) assertTrue(storedProofs.isEmpty()) @@ -824,8 +853,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted( - request, ONCHAIN_ADDRESS, - privatePaymentListVersion = 7uL, previousPrivatePaymentListVersion = 6uL, + request, + ONCHAIN_ADDRESS, + request.amount.atomic, + privatePaymentListVersion = 7uL, + previousPrivatePaymentListVersion = 6uL, ).getOrThrow() val original = storedProofs.single() whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL)) @@ -842,7 +874,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) repo.failOnchainPayment(request) @@ -886,15 +918,23 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() val txid = "ab".repeat(32) repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) - assertFalse(repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid))) + assertFalse( + repo.completeOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted(txid) + ) + ) assertNull(storedProofs.single().paymentIdentifier) assertNull(storedProofs.single().proofData) assertTrue(storedProofs.single().paymentStarted) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test @@ -904,13 +944,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -923,7 +965,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() @@ -939,6 +983,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = periodCaptor.capture(), + conversionQuoteId = isNull(), ) assertEquals(period, periodCaptor.firstValue) } @@ -964,7 +1009,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(listOf(existingProof)) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = currentPeriod) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() @@ -972,7 +1019,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test @@ -1013,15 +1060,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() shouldFailNextSave = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -1031,17 +1080,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() shouldFailNextSave = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -1050,16 +1099,18 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() shouldFailProofRemoval = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertEquals(txid, storedProofs.single().proofData) } @@ -1070,11 +1121,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, endpoint, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() shouldFailNextLoad = true repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) repo.reconcile() @@ -1088,6 +1141,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + conversionQuoteId = isNull(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -1103,11 +1157,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) repo.reconcile() @@ -1120,6 +1176,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + conversionQuoteId = isNull(), ) assertTrue(proofCaptor.firstValue.contains(txid)) } @@ -1129,19 +1186,20 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() val original = storedProofs.single() whenever(lightningRepo.currentOnchainSendAttempt()).thenThrow(IllegalStateException("guard unavailable")) assertFalse( repo.completeOnchainPayment( request, "ab".repeat(32), - MethodId.P2wpkh.rawValue, "bitkit", + MethodId.P2wpkh.rawValue, + "bitkit", OnchainSendOutcome.Accepted("ab".repeat(32)), ) ) assertEquals(listOf(original), storedProofs) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test @@ -1156,16 +1214,18 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.restoreBackup(state.pendingProofs) assertEquals(WalletScope.default, storedProofs.single().onchainWalletId) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(requireNotNull(wire.payerIdentity), PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(requireNotNull(wire.payerIdentity), PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - repo.authorizeOnchainRecovery(attempt).getOrThrow() - assertEquals(state.pendingProofs.single().paymentIdentifier, storedProofs.single().paymentIdentifier) val requestId = requireNotNull(wire.requestId) val request = paymentRequest(state.pendingProofs.single().paymentEndpointIdentifier).copy( paymentRequestId = requestId.paymentRequestId, counterparty = requestId.counterparty, - amountSats = attempt.amountSats, + amount = PaykitAmount(PaykitAsset.BTC, attempt.amountSats), ) + repo.authorizeOnchainRecovery(request, attempt).getOrThrow() + assertEquals(state.pendingProofs.single().paymentIdentifier, storedProofs.single().paymentIdentifier) val winner = attempt.copy(evidence = OnchainSendEvidence.Observed) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(winner) assertTrue(repo.completeRecoveredOnchainPayment(request, winner)) @@ -1186,7 +1246,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val winner = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() storedProofs = storedProofs.map { it.copy(paymentIdentifier = original) } whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( acceptedAttempt(request, winner).copy( @@ -1196,7 +1256,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("delivery temporarily unavailable")) assertTrue( @@ -1223,7 +1283,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val original = "cd".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() storedProofs = storedProofs.map { it.copy(paymentIdentifier = original) } val proof = storedProofs.single() val attempt = acceptedAttempt(request, original).copy( @@ -1234,16 +1294,77 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - repo.authorizeOnchainRecovery(attempt).getOrThrow() + repo.authorizeOnchainRecovery(request, attempt).getOrThrow() assertEquals(listOf(proof), storedProofs) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) storedProofs = listOf(proof, proof.copy(identity = COUNTERPARTY)) - assertTrue(repo.authorizeOnchainRecovery(attempt).isFailure) + assertTrue(repo.authorizeOnchainRecovery(request, attempt).isFailure) assertEquals(listOf(proof, proof.copy(identity = COUNTERPARTY)), storedProofs) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt.copy(payerIdentity = null)) - assertTrue(repo.authorizeOnchainRecovery(attempt.copy(payerIdentity = null)).isFailure) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + assertTrue(repo.authorizeOnchainRecovery(request, attempt.copy(payerIdentity = null)).isFailure) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + } + + @Test + fun `original retry uses the retained conversion quote and rejects expired or changed terms`() = test { + val now = Clock.System.now() + val period = PaykitBillingPeriod(now - 60.seconds, now + 3_600.seconds) + val quote = PaymentConversionQuoteRecord( + eventId = "650e8400-e29b-41d4-a716-446655440000", + billingPeriod = period.sdkValue, + rates = listOf(ConversionRate("btc", "0.00002")), + validFrom = (now - 60.seconds).toString(), + expiresAt = (now + 60.seconds).toString(), + outboundStatus = null, + ) + val request = paymentRequest(MethodId.P2wpkh.rawValue, billingPeriod = period).copy( + amount = PaykitAmount(PaykitAsset.USD, 5_000_000uL), + pricing = PaykitRequestPricing(PaymentConversion.PerPeriod, quotes = listOf(quote)), + ) + val payment = request.payment(MethodId.P2wpkh, now, quoteId = quote.eventId) + val original = "cd".repeat(32) + val repo = paymentProofRepo() + repo.prepare( + request, + MethodId.P2wpkh.rawValue, + "bitkit", + PaykitPaymentProofKind.Onchain, + paymentTerms = payment, + ).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, payment.amount.atomic).getOrThrow() + storedProofs = storedProofs.map { it.copy(paymentIdentifier = original) } + val proof = storedProofs.single() + val attempt = acceptedAttempt(request, original).copy( + amountSats = payment.amount.atomic, + evidence = OnchainSendEvidence.Unknown, + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(original), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + assertEquals(payment.expiresAt, repo.authorizeOnchainRecovery(request, attempt).getOrThrow()) + + val expired = quote.copy(expiresAt = (now - 1.seconds).toString()) + val replacement = quote.copy(eventId = "750e8400-e29b-41d4-a716-446655440000") + assertTrue( + repo.authorizeOnchainRecovery( + request.copy(pricing = request.pricing.copy(quotes = listOf(expired, replacement))), + attempt, + ).isFailure + ) + val repriced = quote.copy(rates = listOf(ConversionRate("btc", "0.00003"))) + assertTrue( + repo.authorizeOnchainRecovery( + request.copy(pricing = request.pricing.copy(quotes = listOf(repriced))), + attempt, + ).isFailure + ) + assertEquals(listOf(proof), storedProofs) } @Test @@ -1252,7 +1373,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val winner = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() storedProofs = storedProofs.map { it.copy(paymentIdentifier = "ef".repeat(32)) } whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( acceptedAttempt(request, winner).copy( @@ -1267,7 +1388,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(storedProofs.single().proofData) assertEquals("ef".repeat(32), storedProofs.single().paymentIdentifier) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) verify(lightningRepo, never()).finishAcceptedShopActivity(any(), any()) } @@ -1282,13 +1403,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) val repo = paymentProofRepo() repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS, firstRequest.amount.atomic).getOrThrow() repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS, secondRequest.amount.atomic).getOrThrow() whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(firstRequest, "ab".repeat(32))) repo.reconcile() @@ -1308,12 +1429,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() repo.reconcile() assertTrue(storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt.isEmpty()) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test @@ -1322,7 +1443,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) val finishing = CompletableDeferred() @@ -1347,7 +1468,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) whenever(lightningRepo.finishAcceptedShopActivity(request.id, txid)) @@ -1364,15 +1485,19 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) repo.reconcile() assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) doReturn(Unit).whenever(lightningRepo).finishAcceptedShopActivity(request.id, txid) repo.reconcile() @@ -1386,6 +1511,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq(MethodId.P2wpkh.rawValue), any(), isNull(), + isNull(), ) } @@ -1395,13 +1521,21 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("delivery unavailable")) .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() - assertTrue(repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid))) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() + assertTrue( + repo.completeOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted(txid) + ) + ) repo.reconcile() assertEquals(txid, storedProofs.single().proofData) assertTrue(storedProofs.single().onchainAcceptanceVerified) @@ -1413,8 +1547,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) val proofCaptor = argumentCaptor() verify(paykitSdkService, times(2)).submitPaymentProof( - eq(request.counterparty), eq(request.paymentRequestId), eq("bitkit"), - eq(MethodId.P2wpkh.rawValue), proofCaptor.capture(), isNull(), + eq(request.counterparty), + eq(request.paymentRequestId), + eq("bitkit"), + eq(MethodId.P2wpkh.rawValue), + proofCaptor.capture(), + isNull(), + isNull(), ) assertTrue(proofCaptor.allValues.all { it.contains(txid) }) verify(lightningRepo, never()).completeAcceptedShopFollowup(newerRequest.id, "cd".repeat(32)) @@ -1435,7 +1574,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo().reconcile() assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) } @@ -1444,27 +1583,35 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) assertFalse(repo.completeOnchainPayment(request, "ab".repeat(32), MethodId.P2wpkh.rawValue, "bitkit")) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test fun `exact positive guard upgrades an unmarked completed proof durably`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) val txid = "ab".repeat(32) - storedProofs = listOf(PendingPaykitPaymentProof( - identity = LOCAL_IDENTITY, requestId = request.id, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentAppId = "bitkit", - kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = txid, proofData = txid, - )) + storedProofs = listOf( + PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = request.id, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = txid, + proofData = txid, + ), + ) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( acceptedAttempt(request, txid).copy(evidence = OnchainSendEvidence.Observed), ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("delivery unavailable")) paymentProofRepo().reconcile() @@ -1477,14 +1624,20 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() - assertFalse(repo.completeOnchainPayment( - request, "ab".repeat(32), MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("cd".repeat(32)), - )) + assertFalse( + repo.completeOnchainPayment( + request, + "ab".repeat(32), + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted("cd".repeat(32)), + ) + ) assertFalse(storedProofs.single().onchainAcceptanceVerified) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test @@ -1502,7 +1655,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo().reconcile() assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) } @@ -1570,7 +1723,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() val original = storedProofs.single() storedProofs = storedProofs + original.copy(onchainWalletId = "foreign-wallet") assertTrue(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) @@ -1583,7 +1736,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() val original = storedProofs.single() assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, true)) assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, "foreign-wallet", LOCAL_IDENTITY, false)) @@ -1622,11 +1775,28 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val txid = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, 7uL).getOrThrow() - assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, - ONCHAIN_ADDRESS, request.amountSats)) - assertTrue(repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, txid, LOCAL_IDENTITY, - ONCHAIN_ADDRESS, request.amountSats, false)) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId, 7uL).getOrThrow() + assertTrue( + repo.retainHardwareOnchainCandidate( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic + ) + ) + assertTrue( + repo.clearHardwareOnchainCandidateBeforeDispatch( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic, + false + ) + ) whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL)) .thenReturn(Result.failure(IllegalStateException("storage")), Result.success(Unit)) assertTrue(runCatching { repo.backupSnapshot() }.isFailure) @@ -1645,16 +1815,35 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val txid = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, - ONCHAIN_ADDRESS, request.amountSats)) - assertTrue(repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, txid, LOCAL_IDENTITY, - ONCHAIN_ADDRESS, request.amountSats, false)) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + assertTrue( + repo.retainHardwareOnchainCandidate( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic + ) + ) + assertTrue( + repo.clearHardwareOnchainCandidateBeforeDispatch( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic, + false + ) + ) // Kill after the durable denial, before the sheet's cleanup callback. val reopened = paymentProofRepo() reopened.reconcile() assertTrue(storedProofs.isEmpty()) - assertTrue(reopened.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isSuccess) + assertTrue( + reopened.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isSuccess + ) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -1665,13 +1854,28 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val txid = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, - ONCHAIN_ADDRESS, request.amountSats)) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + assertTrue( + repo.retainHardwareOnchainCandidate( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic + ) + ) val retained = storedProofs.single() suspend fun clear(prior: Boolean = false, candidate: String = txid, address: String = ONCHAIN_ADDRESS) = - repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, candidate, LOCAL_IDENTITY, - address, request.amountSats, prior) + repo.clearHardwareOnchainCandidateBeforeDispatch( + request.id, + walletId, + candidate, + LOCAL_IDENTITY, + address, + request.amount.atomic, + prior + ) assertFalse(clear(prior = true)) assertFalse(clear(candidate = "cd".repeat(32))) assertFalse(clear(address = "other-address")) @@ -1703,7 +1907,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { "4e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300", 141uL, 2uL, - request.amountSats + 141uL + request.amount.atomic + 141uL ) val txid = SignedTransactionId.fromHex(signed.serializedTx) val repo = paymentProofRepo() @@ -1712,6 +1916,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.markOnchainPaymentStarted( request, ONCHAIN_ADDRESS, + request.amount.atomic, walletId, 7uL, signedTx = signed, @@ -1730,32 +1935,53 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats + request.amount.atomic ) ) - verify(privatePaykitRepo).consumePrivatePaymentList(eq(request.counterparty), argThat { paymentListVersion == 7uL }) + verify( + privatePaykitRepo + ).consumePrivatePaymentList(eq(request.counterparty), argThat { paymentListVersion == 7uL }) val otherRequest = paymentRequest(MethodId.P2wpkh.rawValue, paymentRequestId = "another-order") - assertTrue(reopened.prepare(otherRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isFailure) + assertTrue( + reopened.prepare(otherRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isFailure + ) whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())) .thenReturn(Result.failure(IllegalStateException("private state unavailable"))) var blockedByStorage = false try { - reopened.retainedHardwareOnchainPayment(request.id, walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats) + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic + ) } catch (_: IllegalStateException) { blockedByStorage = true } assertTrue(blockedByStorage) whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())) .thenReturn(Result.failure(PrivatePaykitError.PaymentListAlreadyConsumed)) - assertEquals(signed, reopened.retainedHardwareOnchainPayment(request.id, walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)?.signedTx) - whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())).thenReturn(Result.success(Unit)) + assertEquals( + signed, + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic + )?.signedTx + ) + whenever( + privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any()) + ).thenReturn(Result.success(Unit)) assertNull( reopened.retainedHardwareOnchainPayment( request.id, "other-wallet", LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats + request.amount.atomic ) ) assertNull( @@ -1764,7 +1990,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletId, COUNTERPARTY, ONCHAIN_ADDRESS, - request.amountSats + request.amount.atomic ) ) assertNull( @@ -1773,7 +1999,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletId, LOCAL_IDENTITY, "other-address", - request.amountSats + request.amount.atomic ) ) assertNull( @@ -1782,7 +2008,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats + 1uL + request.amount.atomic + 1uL ) ) assertFalse(storedProofs.single().onchainAcceptanceVerified) @@ -1816,17 +2042,36 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, signed ) ) - assertEquals(false, storedProofs.single().hardwareDispatchAttempted, - "Retaining the signed candidate is not native dispatch") - assertFalse(reopened.markHardwareOnchainDispatch(request.id, walletId, txid, COUNTERPARTY, - ONCHAIN_ADDRESS, request.amountSats)) + assertEquals( + false, + storedProofs.single().hardwareDispatchAttempted, + "Retaining the signed candidate is not native dispatch" + ) + assertFalse( + reopened.markHardwareOnchainDispatch( + request.id, + walletId, + txid, + COUNTERPARTY, + ONCHAIN_ADDRESS, + request.amount.atomic + ) + ) assertEquals(false, storedProofs.single().hardwareDispatchAttempted) - assertTrue(reopened.markHardwareOnchainDispatch(request.id, walletId, txid, LOCAL_IDENTITY, - ONCHAIN_ADDRESS, request.amountSats)) + assertTrue( + reopened.markHardwareOnchainDispatch( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amount.atomic + ) + ) val dispatched = Json.encodeToString(reopened.backupSnapshot()) storedProofs = emptyList() reopened.restoreBackup(Json.decodeFromString>(dispatched)) @@ -1837,7 +2082,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats + request.amount.atomic )?.hasAttemptedBroadcast ) assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) @@ -1851,11 +2096,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) .bufferedReader().readText().trim() - val signed = HwFundingSignedTx(raw, 141uL, 2uL, request.amountSats + 141uL) + val signed = HwFundingSignedTx(raw, 141uL, 2uL, request.amount.atomic + 141uL) val txid = SignedTransactionId.fromHex(raw) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = signed).getOrThrow() + repo.markOnchainPaymentStarted( + request, + ONCHAIN_ADDRESS, + request.amount.atomic, + walletId, + signedTx = signed + ).getOrThrow() assertTrue( repo.markHardwareOnchainDispatch( request.id, @@ -1863,7 +2114,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, ) ) assertFalse( @@ -1873,7 +2124,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { txid, COUNTERPARTY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, ) ) assertTrue( @@ -1883,7 +2134,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, ) ) val reopened = paymentProofRepo() @@ -1892,7 +2143,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, ) assertEquals(RetainedHardwareOnchainPayment(signed, true, true), restored) assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, true)) @@ -1903,7 +2154,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, ) ) assertFalse(storedProofs.single().hardwareRefusedForNavigation) @@ -1918,20 +2169,36 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val txid = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, "other-address", request.amountSats)) - assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats + 1uL)) - assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + suspend fun retainCandidate( + address: String = ONCHAIN_ADDRESS, + amount: ULong = request.amount.atomic, + candidateTxid: String = txid, + ) = repo.retainHardwareOnchainCandidate(request.id, walletId, candidateTxid, LOCAL_IDENTITY, address, amount) + assertFalse(retainCandidate(address = "other-address")) + assertFalse(retainCandidate(amount = request.amount.atomic + 1uL)) + assertTrue(retainCandidate()) assertEquals(txid, storedProofs.single().paymentIdentifier) assertNull(storedProofs.single().proofData) assertFalse(storedProofs.single().onchainAcceptanceVerified) - assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, "cd".repeat(32), LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + assertFalse(retainCandidate(candidateTxid = "cd".repeat(32))) + whenever( + hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) + ).thenReturn(Result.success(true)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(paymentRequestRecord()) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).thenReturn(paymentRequestRecord()) paymentProofRepo().reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(MethodId.P2wpkh.rawValue), - eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull()) + verify(paykitSdkService).submitPaymentProof( + any(), + any(), + any(), + eq(MethodId.P2wpkh.rawValue), + eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), + isNull(), + isNull() + ) assertTrue(storedProofs.isEmpty()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -1943,28 +2210,41 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(false)) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + whenever( + hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) + ).thenReturn(Result.success(false)) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertEquals(txid, storedProofs.single().paymentIdentifier) assertNull(storedProofs.single().proofData) assertFalse(storedProofs.single().onchainAcceptanceVerified) - assertEquals(PaykitPaymentRequestError.OperationInProgress, - repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).exceptionOrNull()) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).exceptionOrNull() + ) repo.failOnchainPayment(request) assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + whenever( + hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) + ).thenReturn(Result.success(true)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, "cd".repeat(32))) paymentProofRepo().reconcile() verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(MethodId.P2wpkh.rawValue), - eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull()) + verify(paykitSdkService).submitPaymentProof( + any(), + any(), + any(), + eq(MethodId.P2wpkh.rawValue), + eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), + isNull(), + isNull() + ) assertTrue(storedProofs.isEmpty()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -1976,23 +2256,25 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - storedProofs = listOf(storedProofs.single().copy( - paymentIdentifier = txid, proofData = txid, onchainAcceptanceVerified = true, - )) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + storedProofs = listOf( + storedProofs.single().copy( + paymentIdentifier = txid, proofData = txid, onchainAcceptanceVerified = true, + ) + ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(false)) paymentProofRepo().reconcile() assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(true)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) paymentProofRepo().reconcile() assertTrue(storedProofs.isEmpty()) - verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -2003,22 +2285,24 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + whenever( + hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) + ).thenReturn(Result.success(true)) // No delivery session: preserve the verified proof durably for later delivery. assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertEquals(txid, storedProofs.single().proofData) assertTrue(storedProofs.single().onchainAcceptanceVerified) assertEquals(walletId, storedProofs.single().onchainWalletId) assertEquals(request.id, repo.onchainPaymentResolutions.value.single().requestId) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(false)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) repo.reconcile() assertTrue(storedProofs.isEmpty()) - verify(hwWalletRepo).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(hwWalletRepo).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) verify(paykitSdkService).submitPaymentProof( any(), any(), @@ -2026,6 +2310,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq(MethodId.P2wpkh.rawValue), eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull(), + isNull(), ) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -2037,22 +2322,22 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(true)) assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) val completed = storedProofs.single() repo.restoreBackup(repo.backupSnapshot()) assertEquals(completed, storedProofs.single()) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(false)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) repo.reconcile() assertEquals(completed, storedProofs.single()) - verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -2063,8 +2348,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val walletId = "original-hardware-wallet" val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.failure(AppError("lookup failed"))) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() + whenever( + hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) + ).thenReturn(Result.failure(AppError("lookup failed"))) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertFalse(repo.completeHardwareOnchainPayment(request.id, "different-wallet", txid, LOCAL_IDENTITY)) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, "cd".repeat(32), LOCAL_IDENTITY)) @@ -2072,9 +2359,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(txid, storedProofs.single().paymentIdentifier) assertNull(storedProofs.single().proofData) assertFalse(storedProofs.single().onchainAcceptanceVerified) - verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -2084,12 +2371,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val txid = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() val original = storedProofs.single() val other = original.copy(identity = COUNTERPARTY) storedProofs = listOf(other) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever( + hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) + ).thenReturn(Result.success(true)) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertEquals(listOf(other), storedProofs) @@ -2099,7 +2390,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertTrue(storedProofs.first { it.identity == LOCAL_IDENTITY }.onchainAcceptanceVerified) assertEquals(other, storedProofs.first { it.identity == COUNTERPARTY }) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -2115,11 +2406,19 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(context.getString(any())).thenReturn("message") val connected = mock() val funding = HwFundingTransaction("psbt", 1000uL, 2.0f, 2000uL, 2uL) - val signed = HwFundingSignedTx(requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")).bufferedReader().readText().trim(), 1000uL, 2uL, 2000uL) + val signed = + HwFundingSignedTx( + requireNotNull( + javaClass.getResourceAsStream("/hardware-signed-transaction.hex") + ).bufferedReader().readText().trim(), + 1000uL, + 2uL, + 2000uL + ) whenever(hwWalletRepo.needsPassphrase(walletId)).thenReturn(false) whenever(hwWalletRepo.reconnectTimeout(walletId)).thenReturn(30.seconds) whenever(hwWalletRepo.ensureConnected(walletId)).thenReturn(Result.success(connected)) - whenever(hwWalletRepo.composeFundingTransaction(walletId, ONCHAIN_ADDRESS, request.amountSats, 2uL)) + whenever(hwWalletRepo.composeFundingTransaction(walletId, ONCHAIN_ADDRESS, request.amount.atomic, 2uL)) .thenReturn(Result.success(funding)) whenever(hwWalletRepo.signFunding(walletId, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)) @@ -2134,7 +2433,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { HwSendRequest( walletId, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, 2uL, emptyList(), request.id, @@ -2142,7 +2441,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), prepareContactPayment = { receipt -> assertEquals(signed, receipt) - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = receipt).getOrThrow() + repo.markOnchainPaymentStarted( + request, + ONCHAIN_ADDRESS, + request.amount.atomic, + walletId, + signedTx = receipt + ).getOrThrow() true }, authorizeContactPayment = { @@ -2158,7 +2463,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, ), ) true @@ -2178,11 +2483,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(send.uiState.value.hasPendingBroadcast) assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) - assertEquals(PaykitPaymentRequestError.OperationInProgress, + assertEquals( + PaykitPaymentRequestError.OperationInProgress, paymentProofRepo().prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) - .exceptionOrNull()) + .exceptionOrNull() + ) verify(hwWalletRepo, times(1)).broadcastFunding(signed) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -2192,7 +2499,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val txid = "ab".repeat(32) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic, walletId).getOrThrow() val original = storedProofs.single() shouldFailNextSave = true @@ -2216,7 +2523,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `retained refused hardware reopen preserves exact receipt and blocks new preparation`() = test { - val request = paymentRequest(MethodId.P2wpkh.rawValue) + val method = MethodId.P2wpkh.rawValue + val request = paymentRequest(method) val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) .bufferedReader().use { it.readText().trim() } val proof = PendingPaykitPaymentProof( @@ -2228,7 +2536,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentStarted = true, paymentIdentifier = SignedTransactionId.fromHex(raw), onchainAddress = ONCHAIN_ADDRESS, - onchainAmountSats = request.amountSats, + onchainAmountSats = request.amount.atomic, onchainWalletId = "trezor:original", hardwareDispatchAttempted = true, hardwareRefusedForNavigation = true, @@ -2247,20 +2555,19 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proof.onchainWalletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, - request.amountSats, + request.amount.atomic, )?.signedTx?.serializedTx, ) - assertNull(reopened.retainedHardwareOnchainRequest(request.copy(amountSats = request.amountSats + 1uL))) + assertNull( + reopened.retainedHardwareOnchainRequest( + request.copy(amount = PaykitAmount(PaykitAsset.BTC, request.amount.atomic + 1uL)) + ) + ) assertNull(reopened.retainedHardwareOnchainRequest(request.copy(paymentRequestId = "foreign-request"))) assertTrue(reopened.hasRetainedHardwareOnchainPayment(proof.onchainWalletId)) assertEquals( PaykitPaymentRequestError.OperationInProgress, - reopened.prepare( - request, - MethodId.P2wpkh.rawValue, - "bitkit", - PaykitPaymentProofKind.Onchain, - ).exceptionOrNull(), + reopened.prepare(request, method, "bitkit", PaykitPaymentProofKind.Onchain).exceptionOrNull(), ) storedProofs = listOf(proof.copy(hardwareRefusedForNavigation = false)) assertNull(reopened.retainedHardwareOnchainRequest(request)) @@ -2271,7 +2578,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(reopened.retainedHardwareOnchainRequest(request)) assertEquals(listOf(proof), storedProofs) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } private fun paymentProofRepo(store: PaykitPaymentProofStore = this.store) = PaykitPaymentProofRepo( @@ -2289,7 +2596,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { requestId = request.id, orderId = null, address = ONCHAIN_ADDRESS, - amountSats = request.amountSats, + amountSats = request.amount.atomic, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, @@ -2306,8 +2613,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, - amountValue = "0.00001", - amountSats = 1_000uL, + paymentReference = "test-reference", + amount = PaykitAmount(PaykitAsset.BTC, 1_000uL), expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf(endpoint), billingPeriod = billingPeriod, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt index ca20fd69a9..c1b8a3deff 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt @@ -3,8 +3,8 @@ package to.bitkit.repositories import kotlinx.serialization.SerializationException import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.eq import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -12,9 +12,9 @@ import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.test.BaseUnitTest import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs -import kotlin.test.assertEquals import kotlin.test.assertTrue class PaykitPaymentProofStoreTest : BaseUnitTest() { @@ -40,9 +40,14 @@ class PaykitPaymentProofStoreTest : BaseUnitTest() { val identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" val requestId = PaykitPaymentRequestId("request", "counterparty") val proof = PendingPaykitPaymentProof( - identity = identity, requestId = requestId, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentAppId = "bitkit", - kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, - paymentIdentifier = "ab".repeat(32), proofData = "ab".repeat(32), + identity = identity, + requestId = requestId, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = "ab".repeat(32), + proofData = "ab".repeat(32), ) var saved: String? = null val keychain = mock() @@ -55,7 +60,10 @@ class PaykitPaymentProofStoreTest : BaseUnitTest() { store.save(listOf(proof.copy(onchainAcceptanceVerified = true))) assertContains(requireNotNull(saved), "\"onchainAcceptanceVerified\":true") - assertEquals(mapOf(requestId to PaykitPaymentProofKind.Onchain), store.completedRequestProofKindsAwaitingSubmission(identity)) + assertEquals( + mapOf(requestId to PaykitPaymentProofKind.Onchain), + store.completedRequestProofKindsAwaitingSubmission(identity) + ) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 119d9acf6f..45596aeecf 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -3,12 +3,14 @@ package to.bitkit.repositories import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.ConversionRate import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitStateObservation +import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -47,6 +49,9 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -83,6 +88,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat private val diagnostics = mock() private val paymentProofStore = mock() private val paymentProofRepo = mock() + private val usdtPayments = mock() private val notificationScheduler = mock() private var schedulerOriginMillis = 0L private val clock = object : Clock { @@ -125,6 +131,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paymentProofStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) .thenReturn(Result.success(emptySet())) + whenever(usdtPayments.verifiedReceipts(any())).thenReturn(Result.success(emptyList())) + whenever(usdtPayments.protectedRequestIdsForSubscriptionCancellation(any(), any())) + .thenReturn(Result.success(emptySet())) sut = PaykitPaymentRequestRepo( testDispatcher, paykitSdkService, @@ -133,9 +142,13 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat diagnostics, paymentProofStore, paymentProofRepo, + usdtPayments, notificationScheduler, clock, subscriptionClock, + mock { on { canReceive() }.thenReturn(true) }, + mock { on { currencyState }.thenReturn(MutableStateFlow(CurrencyState())) }, + mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, ) sut.activate(LOCAL_IDENTITY) } @@ -167,6 +180,71 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.clear() } + @Test + fun `dollar subscription requires usdt receiving and fixes parity for future payments`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), + ) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn(true) + val captured = argumentCaptor() + whenever(paykitSdkService.proposePaymentRequest(any(), captured.capture(), eq(LOCAL_IDENTITY))) + .thenReturn(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE)) + for (enabled in listOf(false, true)) { + val settings = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitUsdtEnabled = enabled) + whenever(settingsStore.data).thenReturn(flowOf(settings)) + val result = sut.proposeSubscription( + PaykitSubscriptionDraft( + amount = PaykitAmount(PaykitAsset.USD, 500uL), + name = "Monthly support", + description = "", + frequency = PaykitRecurrenceUnit.Month, + expiresAt = clock.now() + 60.seconds, + ), + target, + listOf(COUNTERPARTY), + ) + if (!PublicPaykitRepo.isUsdtPaymentOptionEnabled(settings, UsdtWalletState())) { + assertEquals(PaykitPaymentRequestError.RequestUnavailable, result.exceptionOrNull()) + assertTrue(captured.allValues.isEmpty()) + continue + } + result.getOrThrow() + val terms = captured.lastValue + assertEquals(listOf(MethodId.UsdtArbitrum.rawValue), terms.acceptedPaymentEndpointIdentifiers) + assertEquals(PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1"))), terms.conversion) + val later = clock.now() + (40 * 86400).seconds + val period = PaykitBillingPeriod(later, later + (30 * 86400).seconds) + val pricing = PaykitRequestPricing(terms.conversion, terms.paymentDeadline) + val payment = pricing.payment( + PaykitAmount.parse(PaykitAsset.USD, terms.amountValue), + MethodId.UsdtArbitrum.rawValue, + period, + later, + ) + assertEquals("5", payment.amount.value) + assertEquals(null, payment.quoteId) + assertTrue(payment.isValid(later)) + assertTrue(pricing.quotes.isEmpty()) + } + } + + @Test + fun `refresh preserves all supported subscription denominations`() = test { + val records = PaykitAsset.entries.map { asset -> + val record = paymentRequestRecord(id = asset.code, state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + record.copy(terms = requireNotNull(record.terms).copy(amount = PaymentRequestAmount("1", asset.code))) + } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(records) + sut.refresh().getOrThrow() + assertEquals(PaykitAsset.entries.toSet(), sut.subscriptions.value.map { it.amount.asset }.toSet()) + assertEquals(PaykitAsset.entries.toSet(), sut.pendingRequests.value.map { it.amount.asset }.toSet()) + assertTrue(sut.pendingRequests.value.all { it.amount.value == "1" }) + } + @Test fun `inbox refresh maps active subscription and exposes current unpaid period`() = test { val metadataText = """ @@ -360,6 +438,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(PaykitPaymentProofKind.Lightning, received.paymentProofKind) } + @Suppress("LongMethod") @Test fun `creator proposal sends recurring terms and stays queued until delivery`() = test { val target = stubSubscriptionProposal() @@ -373,7 +452,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val proposal = async { sut.proposeSubscription( draft = PaykitSubscriptionDraft( - amountSats = 100_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 100_000uL), name = " Monthly support ", description = " Thank you ", frequency = PaykitRecurrenceUnit.Month, @@ -401,6 +480,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } with(captured.firstValue) { assertEquals("0.001", amountValue) + assertFalse(MethodId.UsdtArbitrum.rawValue in acceptedPaymentEndpointIdentifiers) + assertTrue(acceptedPaymentEndpointIdentifiers.isNotEmpty()) + assertEquals(null, conversion) assertEquals(expiresAt.toString(), proposalExpiresAt) assertEquals(1u, recurrence?.every) assertEquals("month", recurrence?.unit) @@ -426,7 +508,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val creation = sut.proposeSubscription( draft = PaykitSubscriptionDraft( - amountSats = 100_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 100_000uL), name = "Monthly support", description = "Thank you", frequency = PaykitRecurrenceUnit.Month, @@ -449,7 +531,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.proposeSubscription( draft = PaykitSubscriptionDraft( - amountSats = 100_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 100_000uL), name = "Monthly support", description = "Thank you", frequency = PaykitRecurrenceUnit.Month, @@ -482,7 +564,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> val result = sut.proposeSubscription( draft = PaykitSubscriptionDraft( - amountSats = 1000uL, + amount = PaykitAmount(PaykitAsset.BTC, 1000uL), name = "Support", description = "💜".repeat(256), frequency = PaykitRecurrenceUnit.Month, @@ -1070,7 +1152,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } @Test - fun `deadline subscriptions retain paid periods and cancellation without offering payments`() = test { + fun `recurring payment deadlines retain paid history and bound unpaid amounts`() = test { advanceTimeBy(32 * 24 * 60 * 60 * 1000L) val proof = mock { on { billingPeriod } doReturn BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") @@ -1090,12 +1172,14 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.refresh().getOrThrow() - assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.pendingRequests.value.all { it.isPaymentDeadlineExpired(clock.now()) }) + val unpaid = requireNotNull(sut.subscriptions.value.single { it.isPayer }.paymentDueOnAcceptance(clock.now())) + assertTrue(unpaid.isPaymentDeadlineExpired(clock.now())) + assertTrue(!unpaid.payment(MethodId.Bolt11, clock.now()).isValid(clock.now())) assertEquals(2, sut.subscriptions.value.size) sut.subscriptions.value.forEach { subscription -> assertEquals(1, subscription.paidPeriods.size) assertTrue(subscription.canCancel(clock.now())) - assertEquals(null, subscription.paymentDueOnAcceptance(clock.now())) } val paid = sut.paymentRequestHistory.value.single() assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, paid.lifecycleState) @@ -1202,6 +1286,48 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), anyOrNull()) } } + @Test + fun `subscription cannot be canceled while USDT execution is unresolved`() = test { + val requestId = PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY, "2027-01-01T08:00:00Z") + whenever(usdtPayments.protectedRequestIdsForSubscriptionCancellation(eq(LOCAL_IDENTITY), any())) + .thenReturn(Result.success(setOf(requestId))) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)) + ) + sut.refresh().getOrThrow() + + val result = sut.cancel(sut.subscriptions.value.single()) + + assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) + verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), anyOrNull()) } + } + + @Test + fun `an in-flight cancellation prevents authorizing a newly started payment`() = test { + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + val canceled = active.copy(state = PaymentRequestLifecycleState.CANCELED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.ensurePaymentAllowed(request).getOrThrow() + val started = CompletableDeferred() + val finish = CompletableDeferred() + whenever(paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID)).doSuspendableAnswer { + started.complete(Unit) + finish.await() + canceled + } + val cancellation = async { sut.cancel(sut.subscriptions.value.single()) } + started.await() + + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(canceled)) + finish.complete(Unit) + cancellation.await().getOrThrow() + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + @Test fun `subscription cancellation proceeds without a started payment`() = test { val active = paymentRequestRecord( @@ -1242,12 +1368,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val subscriptions = sut.subscriptions.value assertEquals(setOf("deadline", "unsupported"), subscriptions.map { it.paymentRequestId }.toSet()) - assertTrue(subscriptions.none { it.isProposalActionable(clock.now()) }) + assertEquals( + listOf("deadline"), + subscriptions.filter { + it.isProposalActionable(clock.now()) + }.map { it.paymentRequestId } + ) assertEquals(subscriptions, sut.subscriptionProposals()) val deadlineSubscription = subscriptions.first { it.paymentRequestId == "deadline" } - assertEquals(null, deadlineSubscription.paymentDueOnAcceptance(clock.now())) - assertTrue(sut.accept(deadlineSubscription).exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) - verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + assertTrue(deadlineSubscription.paymentDueOnAcceptance(clock.now()) != null) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index b0b703fa6a..4f4242d180 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -29,6 +29,7 @@ import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateOperationError import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyIdentityCapability +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -61,6 +62,9 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.models.FxRate +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.safe import to.bitkit.services.PaykitPaymentRequestProposalTerms @@ -69,12 +73,15 @@ import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFails import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.seconds @@ -105,7 +112,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val endpoints: List, val expected: ULong, ) - val cases = listOf( + listOf( Case("usd", "1000", listOf(ConversionRate("btc", "0.001")), listOf("btc-lightning-bolt11"), 100_000_000uL), Case("usdt", "12.34", listOf(ConversionRate("btc", "0.00002")), listOf("btc-lightning-lnurl"), 24_680uL), Case("btc", "0.001", listOf(ConversionRate("btc", "0.5")), listOf("btc-lightning-bolt11"), 50_000uL), @@ -133,8 +140,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf("btc-regtest-p2wpkh", "btc-lightning-bolt11", "btc-lightning-lnurl"), 21_000uL, ), - ) - cases.forEach { + ).forEach { val record = paymentRequestRecord( amount = it.amount, asset = it.asset, @@ -143,48 +149,40 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val result = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed val request = result.request - assertEquals(it.expected, request.amountSats) assertEquals(it.amount, request.amountValue) - assertEquals(it.asset, record.terms?.amount?.asset) - assertEquals(PaymentConversion.Fixed(it.rates), record.terms?.conversion) - assertTrue(request.acceptsPaymentAmount(it.expected)) - assertFalse(request.acceptsPaymentAmount(it.expected.safe() + 1uL.safe())) - val expectedMsats = it.expected.safe() * 1000uL.safe() - assertTrue(request.acceptsLightningInvoiceAmountMsats(expectedMsats)) - assertFalse(request.acceptsLightningInvoiceAmountMsats(expectedMsats.safe() + 1uL.safe())) + for (endpoint in request.acceptedPaymentEndpointIdentifiers) { + val method = checkNotNull(MethodId.fromRawValue(endpoint)) + assertEquals(it.expected, request.payment(method, clock.now()).amount.atomic) + assertTrue(request.acceptsPaymentAmount(it.expected, method, at = clock.now())) + assertFalse(request.acceptsPaymentAmount(it.expected.safe() + 1uL.safe(), method, at = clock.now())) + if (method == MethodId.Bolt11 || method == MethodId.Lnurl) { + val msats = it.expected.safe() * 1000uL.safe() + assertTrue(request.acceptsLightningInvoiceAmountMsats(msats, at = clock.now())) + assertFalse(request.acceptsLightningInvoiceAmountMsats(msats.safe() + 1uL.safe(), at = clock.now())) + } + } } } @Test - fun `fixed pricing rejects products beyond exact decimal precision`() { - val cases = listOf( - Triple("9." + "0".repeat(35) + "1", "0.00004", 36_001uL), - Triple("9." + "0".repeat(36) + "1", "0.00004", null), - Triple("9." + "0".repeat(36) + "1", "1", null), - Triple("0009." + "0".repeat(35) + "10", "0.0000400", 36_001uL), - ) - cases.forEach { (amount, rate, expected) -> + fun `fixed pricing rejects requested amounts beyond currency precision`() { + for (amount in listOf("9.001", "9." + "0".repeat(35) + "1")) { val record = paymentRequestRecord( amount = amount, asset = "usd", - conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", rate))), + conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.00004"))), endpoints = listOf("btc-regtest-p2wpkh"), ) - val result = record.parseIncomingPaykitPaymentRequest(clock.now()) - if (expected != null) { - assertEquals(expected, (result as PaykitPaymentRequestParseResult.Parsed).request.amountSats) - } else { - assertEquals( - PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing), - result, - "$amount * $rate", - ) - } + assertEquals( + PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount), + record.parseIncomingPaykitPaymentRequest(clock.now()), + amount, + ) } } @Test - fun `fixed pricing omits unquoted cross asset rails`() { + fun `fixed pricing only permits quoted cross asset rails`() { val record = paymentRequestRecord( amount = "1", asset = "usd", @@ -192,11 +190,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { endpoints = listOf("btc-lightning-bolt11", "btc-regtest-p2wpkh", "btc-regtest-p2tr"), ) val result = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed - assertEquals(21_000uL, result.request.amountSats) - assertEquals( - listOf("btc-regtest-p2wpkh", "btc-regtest-p2tr"), - result.request.acceptedPaymentEndpointIdentifiers, - ) + assertEquals(21_000uL, result.request.payment(MethodId.P2wpkh, clock.now()).amount.atomic) + assertEquals(21_000uL, result.request.payment(MethodId.P2tr, clock.now()).amount.atomic) + assertFails { result.request.payment(MethodId.Bolt11, clock.now()) } } @Test @@ -206,10 +202,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf(ConversionRate("btc", "0")), listOf(ConversionRate("btc", "-1")), listOf(ConversionRate("btc", "1e-3")), listOf(ConversionRate("btc", " 1")), listOf(ConversionRate("btc", "1\n")), listOf(ConversionRate("btc", "184467440738")), - listOf(ConversionRate("btc", "0.000000011")), listOf(ConversionRate("btc", "0.123456789012345678901234567890123456789")), listOf(ConversionRate("btc", "1"), ConversionRate("btc", "2")), - listOf(ConversionRate("btc", "1"), ConversionRate("btc-lightning", "2")), ) (rates.map { PaymentConversion.Fixed(it) } + PaymentConversion.PerPeriod).forEach { val record = paymentRequestRecord( @@ -218,30 +212,32 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = it, endpoints = listOf("btc-regtest-p2wpkh", "btc-lightning-bolt11"), ) - assertEquals( - PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing), - record.parseIncomingPaykitPaymentRequest(clock.now()), - ) + val result = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed + assertFails { result.request.payment(MethodId.P2wpkh, clock.now()) } + assertFails { result.request.payment(MethodId.Bolt11, clock.now()) } } } @Test - fun `conversion subscriptions remain unsupported`() { + fun `conversion subscriptions preserve their pricing terms`() { val record = paymentRequestRecord( conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.5"))), recurrence = PaymentRequestRecurrence(1u, "month", "2027-01-01T00:00:00Z", "2027-01-01T00:00:00Z", null), ) - assertNull(record.toPaykitSubscription()) + val subscription = assertNotNull(record.toPaykitSubscription()) + assertEquals(record.terms?.conversion, subscription.pricing.conversion) } private val paykitSdkService = mock() private val settingsStore = mock() + private val lightningRepo = mock() private val presentationStore = mock() private val diagnostics = mock() private val paymentProofStore = mock() private val proofStateVersion = MutableStateFlow(0L) private val paymentSubmissionActive = MutableStateFlow(false) private val paymentProofRepo = mock() + private val usdtPayments = mock() private val subscriptionNotificationScheduler = mock() private var schedulerOriginMillis = 0L private val clock = object : Clock { @@ -259,6 +255,47 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentSubmissionActive.value = it.getArgument(0) Unit } + stubMessageProcessing() + whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) + whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(presentationStore.loadAcceptedOneTimeIds(any())).thenReturn(emptySet()) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenAnswer { + setOf(it.getArgument(1)) + } + whenever(presentationStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever( + presentationStore.loadSubscriptionState(any()) + ).thenReturn(PaykitSubscriptionPresentationState()) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.backupStateVersion).thenReturn(proofStateVersion) + whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) + .thenReturn(Result.success(emptySet())) + whenever(usdtPayments.verifiedReceipts(any())).thenReturn(Result.success(emptyList())) + whenever(usdtPayments.protectedRequestIdsForSubscriptionCancellation(any(), any())) + .thenReturn(Result.success(emptySet())) + sut = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + presentationStore, + diagnostics, + paymentProofStore, + paymentProofRepo, + usdtPayments, + subscriptionNotificationScheduler, + clock, + clock, + lightningRepo, + currencyRepoWithRate(), + mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, + ) + sut.activate(LOCAL_IDENTITY) + } + + private suspend fun stubMessageProcessing() { whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.processOutboundPrivateMessages(any())).thenReturn( OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), @@ -299,9 +336,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { diagnostics, paymentProofStore, paymentProofRepo, + usdtPayments, subscriptionNotificationScheduler, clock, clock, + lightningRepo, + currencyRepoWithRate(), + mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, ) sut.activate(LOCAL_IDENTITY) } @@ -328,6 +369,29 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + private fun currencyRepoWithRate(): CurrencyRepo = + mock { + on { currencyState }.thenReturn( + MutableStateFlow( + CurrencyState( + rates = persistentListOf( + FxRate( + symbol = "BTCUSD", + lastPrice = "100000", + base = "BTC", + baseName = "Bitcoin", + quote = "USD", + quoteName = "US Dollar", + currencySymbol = "$", + currencyFlag = "", + lastUpdatedAt = clock.now().toEpochMilliseconds(), + ), + ), + ), + ), + ) + } + @After fun tearDown() = test { sut.clear() @@ -852,7 +916,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() - assertEquals(100_000uL, request.amountSats) + assertEquals(100_000uL, request.amount.atomic) assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) } @@ -907,6 +971,26 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) } + @Test + fun `fixed unknown denominations retain the prior bitcoin fallback without applying the rate twice`() { + for (asset in listOf("eur", "gbp")) { + val deadline = PaymentDeadline.At((clock.now() + 1.hours).toString()) + val record = paymentRequestRecord( + asset = asset, + amount = "10", + conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.000011"))), + paymentDeadline = deadline, + endpoints = listOf(MethodId.Bolt11.rawValue, MethodId.UsdtArbitrum.rawValue), + ) + val parsed = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed + assertEquals(PaykitAmount(PaykitAsset.BTC, 11_000u), parsed.request.amount) + assertEquals(listOf(MethodId.Bolt11.rawValue), parsed.request.acceptedPaymentEndpointIdentifiers) + assertEquals(parsed.request.amount, parsed.request.payment(MethodId.Bolt11, clock.now()).amount) + assertEquals(deadline, parsed.request.pricing.deadline) + assertEquals(asset, record.terms?.amount?.asset) + } + } + @Test fun `incoming parse failures are reason specific`() { val cases = listOf( @@ -1003,7 +1087,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh().getOrThrow() assertEquals(listOf("millisatoshi-safe-max"), sut.pendingRequests.value.map { it.paymentRequestId }) - assertEquals(listOf(ULong.MAX_VALUE / 1000uL), sut.pendingRequests.value.map { it.amountSats }) + assertEquals(listOf(ULong.MAX_VALUE / 1000uL), sut.pendingRequests.value.map { it.amount.atomic }) } @Test @@ -1011,8 +1095,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = PaykitPaymentRequest( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - amountValue = "0.000025", - amountSats = 2_500uL, + paymentReference = "test-reference", + amount = PaykitAmount(PaykitAsset.BTC, 2_500uL), expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), ) @@ -1605,9 +1689,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { diagnostics, paymentProofStore, paymentProofRepo, + usdtPayments, subscriptionNotificationScheduler, clock, clock, + lightningRepo, + mock(), + mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, ) other.activate(LOCAL_IDENTITY) var record = paymentRequestRecord() @@ -1783,55 +1871,51 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `proposal uses exact linked capable path and canonical bitcoin terms`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY) - whenever( - paykitSdkService.identityStatus() - ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), - ) - whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( - true, - ) - whenever( - paykitSdkService.proposePaymentRequest( - eq(COUNTERPARTY), - any(), - eq(LOCAL_IDENTITY), - ), - ).thenReturn( - paymentRequestRecord( - role = PaymentRequestLocalRole.PAYEE, - counterparty = COUNTERPARTY, - ), - ) + fun `proposal uses canonical terms and accepts available selected methods`() = test { + val target = + stubProposal(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY)) val expiry = clock.now().plus(60.seconds) - val creation = sut.propose( - draft = PaykitPaymentRequestDraft(amountSats = 1uL, note = " Lunch ", expiresAt = expiry), - target = target, - savedPublicKeys = listOf(COUNTERPARTY), - ).getOrThrow() - val request = creation.request - - val proposal = argumentCaptor() - verifyBlocking(paykitSdkService) { - proposePaymentRequest( - eq(COUNTERPARTY), - proposal.capture(), - eq(LOCAL_IDENTITY), - ) + val onchain = MethodId.entries.first { it.isOnchain }.rawValue + for ((canReceiveLightning, restriction) in listOf(false to null, true to null, true to listOf(onchain))) { + whenever(lightningRepo.canReceive()).thenReturn(canReceiveLightning) + clearInvocations(paykitSdkService) + val creation = sut.propose( + draft = PaykitPaymentRequestDraft( + amount = PaykitAmount(PaykitAsset.BTC, 1uL), + note = " Lunch ", + expiresAt = expiry, + acceptedPaymentEndpointIdentifiers = restriction, + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + val request = creation.request + + val proposal = argumentCaptor() + verifyBlocking(paykitSdkService) { + proposePaymentRequest(eq(COUNTERPARTY), proposal.capture(), eq(LOCAL_IDENTITY)) + } + assertEquals("0.00000001", proposal.firstValue.amountValue) + assertTrue(proposal.firstValue.paymentReference.startsWith("bitkit-")) + assertEquals(expiry.toString(), proposal.firstValue.proposalExpiresAt) + val accepted = proposal.firstValue.acceptedPaymentEndpointIdentifiers + if (restriction != null) { + assertEquals(restriction, accepted) + } else { + assertTrue(accepted.contains(onchain)) + assertEquals(canReceiveLightning, accepted.contains(MethodId.Bolt11.rawValue)) + assertEquals( + PublicPaykitRepo.isUsdtPaymentOptionEnabled(SettingsData(), UsdtWalletState()), + accepted.contains(MethodId.UsdtArbitrum.rawValue), + ) + } + assertEquals("{\"note\":\"Lunch\"}", proposal.firstValue.metadataJson) + assertEquals("Lunch", request.note) + assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, request.deliveryStatus) + assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) + assertTrue(creation.wasPublishedToActiveState) } - assertEquals("0.00000001", proposal.firstValue.amountValue) - assertTrue(proposal.firstValue.paymentReference.startsWith("bitkit-")) - assertEquals(expiry.toString(), proposal.firstValue.proposalExpiresAt) - assertTrue(proposal.firstValue.acceptedPaymentEndpointIdentifiers.isNotEmpty()) - assertEquals("{\"note\":\"Lunch\"}", proposal.firstValue.metadataJson) - assertEquals("Lunch", request.note) - assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, request.deliveryStatus) - assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) - assertTrue(creation.wasPublishedToActiveState) verify(paykitSdkService).identityStatus(Priority.Interactive) verify(paykitSdkService).linkedPeers(Priority.Interactive) verify(paykitSdkService).processOutboundPrivateMessages(COUNTERPARTY, Priority.Interactive) @@ -1855,7 +1939,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.allPaymentRequests(LOCAL_IDENTITY)).thenReturn(listOfNotNull(freshRecord)) val creation = sut.propose( - PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + PaykitPaymentRequestDraft(PaykitAmount(PaykitAsset.BTC, 1uL), "Lunch", clock.now().plus(60.seconds)), target, listOf(COUNTERPARTY), ).getOrThrow() @@ -1892,7 +1976,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { clearInvocations(paykitSdkService) val creation = sut.propose( - PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + PaykitPaymentRequestDraft(PaykitAmount(PaykitAsset.BTC, 1uL), "Lunch", clock.now().plus(60.seconds)), target, listOf(COUNTERPARTY), ).getOrThrow() @@ -1911,7 +1995,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `proposal remains created after status read failure and propagates status read cancellation`() = test { val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalOutboundMessageId = 7uL) val target = stubProposal(record) - val draft = PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)) + val draft = PaykitPaymentRequestDraft(PaykitAmount(PaykitAsset.BTC, 1uL), "Lunch", clock.now().plus(60.seconds)) var cancelled = false whenever(paykitSdkService.allPaymentRequests(LOCAL_IDENTITY)).doSuspendableAnswer { if (cancelled) throw CancellationException() @@ -1938,7 +2022,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val creation = sut.propose( - PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + PaykitPaymentRequestDraft(PaykitAmount(PaykitAsset.BTC, 1uL), "Lunch", clock.now().plus(60.seconds)), target, listOf(COUNTERPARTY), ).getOrThrow() @@ -1949,7 +2033,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `proposal revalidates and drains only the selected saved contact`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY) + val target = + stubProposal( + paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalOutboundMessageId = 7uL) + ) val stalledDiscovery = CompletableDeferred() val unrelatedDelivery = CompletableDeferred() whenever(paykitSdkService.processPendingPrivateMessages()).doSuspendableAnswer { @@ -1963,32 +2050,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.processOutboundPrivateMessages(COUNTERPARTY)).thenReturn( OutboundPrivateSendReport(listOf(7uL), listOf(7uL), emptyList(), emptyList(), emptyList()), ) - whenever( - paykitSdkService.identityStatus() - ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( - true, - ) whenever(paykitSdkService.canReceivePaymentRequests(eq(SECOND_IDENTITY), any())).doSuspendableAnswer { stalledDiscovery.await() true } - whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).thenReturn( - paymentRequestRecord( - role = PaymentRequestLocalRole.PAYEE, - counterparty = COUNTERPARTY, - ).copy(proposalOutboundMessageId = 7uL), - ) - val proposal = async { sut.propose( - draft = PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + draft = PaykitPaymentRequestDraft( + PaykitAmount(PaykitAsset.BTC, 1uL), + "Lunch", + clock.now().plus(60.seconds) + ), target = target, savedPublicKeys = listOf(SECOND_IDENTITY, COUNTERPARTY), ) @@ -2031,7 +2109,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proposal = async { sut.propose( - draft = PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + draft = PaykitPaymentRequestDraft( + PaykitAmount(PaykitAsset.BTC, 1uL), + "Lunch", + clock.now().plus(60.seconds) + ), target = target, savedPublicKeys = listOf(COUNTERPARTY), ).getOrThrow() @@ -2560,7 +2642,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFailsWith { sut.propose( - draft = PaykitPaymentRequestDraft(1uL, "", clock.now()), + draft = PaykitPaymentRequestDraft(PaykitAmount(PaykitAsset.BTC, 1uL), "", clock.now()), target = target, savedPublicKeys = listOf(COUNTERPARTY), ).getOrThrow() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index 572f7d7d08..9ea0a9c24e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -21,11 +21,13 @@ import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations 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 org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY @@ -282,19 +284,6 @@ class PaykitSubscriptionNotificationSchedulerTest { verify(workClient).cancelUniqueWork(WORK_NAME) } - @Test - fun `deadline subscriptions do not schedule payment reminders`() { - sut.synchronize( - subscriptions = listOf(subscription().copy(hasPaymentDeadline = true)), - acceptedAt = { NOW }, - pendingRequestIds = emptySet(), - payerIdentity = PAYER_IDENTITY, - notificationsEnabled = true, - ) - - verify(workClient, never()).enqueueUniqueWork(any(), any(), any()) - } - @Test fun `disabling and canceling clear tagged work`() { sut.synchronize( @@ -322,8 +311,9 @@ class PaykitSubscriptionNotificationSchedulerTest { private fun subscription() = PaykitSubscription( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - amountValue = "0.00025", - amountSats = 25_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 25_000uL), + paymentReference = "fixture-reference", + pricing = PaykitRequestPricing(), note = "Weekly coffee", createdAt = NOW, proposalExpiresAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt index 437899bea1..a8baf8b9c9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt @@ -45,6 +45,7 @@ class PaykitSubscriptionProposalTest { private fun terms(description: String, iconUri: String? = null) = PaykitPaymentRequestProposalTerms( amountValue = "0.001", + amountAsset = "btc", paymentReference = "bitkit-00000000-0000-0000-0000-000000000000", proposalExpiresAt = "2027-01-22T08:00:00.000Z", recurrence = PaykitPaymentRequestRecurrenceTerms( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt index 7751a3f744..b764a92671 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt @@ -4,6 +4,9 @@ package to.bitkit.repositories import com.synonym.paykit.PaymentRequestLifecycleState import org.junit.Test +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import java.util.TimeZone import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -179,8 +182,9 @@ class PaykitSubscriptionTest { val subscription = PaykitSubscription( paymentRequestId = "shared", counterparty = "counterparty-a", - amountValue = "0.001", - amountSats = 100_000uL, + amount = PaykitAmount(PaykitAsset.BTC, 100_000uL), + paymentReference = "fixture-reference", + pricing = PaykitRequestPricing(), note = null, createdAt = null, proposalExpiresAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoRecoveryTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoRecoveryTest.kt new file mode 100644 index 0000000000..bad5dcfb09 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoRecoveryTest.kt @@ -0,0 +1,369 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentProof +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.UsdtWallet +import com.synonym.bitkitcore.usdtParsePaymentRequest +import com.synonym.paykit.ConversionRate +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PubkyIdentityCapability +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.json +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing +import to.bitkit.models.PaykitUsdt +import to.bitkit.models.PaykitUsdtStateBackup +import to.bitkit.models.WalletBackupV1 +import to.bitkit.services.PaykitSdkService +import to.bitkit.services.UsdtService +import to.bitkit.test.BaseUnitTest +import to.bitkit.viewmodels.ContactPaymentContext +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class PaykitUsdtPaymentRepoRecoveryTest : BaseUnitTest() { + private val identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val address = "0x1111111111111111111111111111111111111111" + private val keychain: Keychain = mock() + private val sdk: PaykitSdkService = mock() + private val proofs: PaykitPaymentProofRepo = mock() + private val usdt: UsdtRepo = mock() + private val service: UsdtService = mock() + private val wallet: UsdtWallet = mock() + private val clock = object : Clock { override fun now() = Instant.fromEpochSeconds(1_800_000_000) } + private var stored: String? = null + private lateinit var core: MockedStatic<*> + private val request = PaykitPaymentRequest( + paymentRequestId = "550e8400-e29b-41d4-a716-446655440000", + counterparty = contact, + amount = PaykitAmount(PaykitAsset.USD, 5u), + paymentReference = "payment-reference", + pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1")))), + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue), + ) + private val quote = UsdtQuote( + id = "quote", + bridgeProvider = null, + recipient = address, + destination = UsdtDestination.ARBITRUM, + amount = 50_000u, + receivedAmount = 50_000u, + maximumFee = 100u, + expiresAt = 1_800_000_100u, + ) + private val amount = PaykitAmount(PaykitAsset.USDT, 50_000u) + + @Before + fun setUp() = test { + core = Mockito.mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")) + core.`when` { usdtParsePaymentRequest(address) } + .thenReturn(UsdtPaymentRequest(address, null, null)) + val endpoint = PaykitUsdt.endpoint(address) + whenever(keychain.loadString(any())).thenAnswer { stored } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { stored = it.getArgument(1) } + whenever( + sdk.identityStatus(any()) + ).thenReturn(IdentityStatus(identity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.paymentRequests()).thenReturn(emptyList()) + whenever(usdt.paymentEndpoint()).thenReturn(Result.success(endpoint)) + whenever(service.wallet()).thenReturn(wallet) + whenever(usdt.operation(any())).doSuspendableAnswer { + val block = it.getArgument Any?>(0) + runCatching { block(service) } + } + stubStoredTransfer(Result.success(null)) + } + + @After + fun tearDown() { if (::core.isInitialized) core.close() } + + private fun stubStoredTransfer(result: Result) { + result.getOrNull()?.let { whenever(it.id).thenReturn(quote.id) } + doAnswer { result.getOrThrow()?.let(::listOf).orEmpty() }.whenever(wallet).history() + } + + private suspend fun stubSend(result: Result) { + whenever(usdt.send(any(), anyOrNull(), any())).doSuspendableAnswer { invocation -> + runSuspendCatching { + invocation.getArgument Unit>(2).invoke() + result.getOrThrow() + } + } + } + + private fun repo() = PaykitUsdtPaymentRepo(testDispatcher, keychain, sdk, proofs, usdt, clock) + private fun context(request: PaykitPaymentRequest = this.request) = + ContactPaymentContext( + contact, + privatePaymentContext = PrivatePaykitPaymentContext( + mapOf(MethodId.UsdtArbitrum.rawValue to "bitkit"), + null + ), + incomingPaymentRequest = request, + endpoints = listOf(PaykitUsdt.endpoint(address)) + ) + + @Test + fun `unsubmitted binding requires current journal evidence after restart`() = test { + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + stubSend(Result.failure(UsdtException.QuoteExpired())) + assertTrue(repo.send(quote).isFailure) + assertTrue(repo.hasBinding(request.id)) + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + val restarted = repo() + assertTrue(restarted.hasBinding(request.id)) + restarted.reconcile().getOrThrow() + assertFalse(restarted.hasBinding(request.id)) + + for (history in listOf( + Result.failure(UsdtException.Storage("unreadable history")), + Result.success(mock { on { status }.thenReturn(UsdtTransferStatus.PENDING) }), + Result.success(mock { on { status }.thenReturn(UsdtTransferStatus.CONFIRMED) }), + )) { + stubStoredTransfer(history) + whenever(proofs.submit(any())).thenReturn(Result.success(false)) + restarted.reconcile().getOrThrow() + assertTrue(restarted.hasBinding(request.id)) + } + } + + @Test + fun `changed or unreadable persistence invalidates unsubmitted binding evidence`() = test { + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + val unsubmittedState = stored + val original = repo.backupSnapshot().attempts.single() + val proof = PaykitUsdtProof(UsdtPaymentProof("42161", "transaction", "0", "signature")) + for (changed in listOf( + original.copy(paymentStarted = true), + original.copy(proof = proof), + original.copy(proofQueued = true), + original.copy(quoteId = "replacement"), + )) { + stored = unsubmittedState + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + stored = Json.encodeToString(PaykitUsdtStateBackup(listOf(changed), emptyList())) + assertTrue(repo.hasBinding(request.id)) + } + stored = unsubmittedState + repo.reconcile().getOrThrow() + repo.restoreBackup(PaykitUsdtStateBackup(listOf(original), emptyList())) + assertTrue(repo.hasBinding(request.id)) + assertTrue(repo.resumableRequests.value.isEmpty()) + stubStoredTransfer(Result.success(mock { on { status }.thenReturn(UsdtTransferStatus.PENDING) })) + repo.reconcile().getOrThrow() + assertTrue(repo.hasBinding(request.id)) + stubStoredTransfer(Result.success(null)) + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + repo.restoreBackup(PaykitUsdtStateBackup(listOf(original.copy(paymentStarted = true)), emptyList())) + assertTrue(repo.hasBinding(request.id)) + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + stored = unsubmittedState + repo.reconcile().getOrThrow() + stored = "unreadable" + assertTrue(repo.hasBinding(request.id)) + } + + @Test + fun `execution authorization failure releases an unsubmitted attempt`() = test { + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + stubSend(Result.success(mock())) + val result = repo.send(quote) { throw PaykitPaymentRequestError.RequestUnavailable } + assertTrue(result.isFailure) + assertEquals(false, repo.attempts.value.single().paymentStarted) + } + + @Test + fun `binding survives restart and blocks replacement of a pending payment`() = test { + repo().prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + assertNotNull(stored) + val pending = mock { on { status }.thenReturn(UsdtTransferStatus.PENDING) } + stubStoredTransfer(Result.success(pending)) + val restarted = repo() + assertTrue( + restarted.prepare( + context(), + quote.copy(id = "replacement"), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ).isFailure + ) + restarted.reconcile().getOrThrow() + assertEquals(quote.id, restarted.attempts.value.single().quoteId) + assertTrue(restarted.resumableRequests.value.isEmpty()) + stubStoredTransfer(Result.success(null)) + restarted.reconcile().getOrThrow() + assertEquals(setOf(request.id), restarted.resumableRequests.value) + restarted.prepare( + context(), + quote.copy(id = "replacement"), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ).getOrThrow() + assertEquals("replacement", restarted.attempts.value.single().quoteId) + } + + @Test + fun `failed send allows replacement only when its execution journal confirms no submission`() = test { + val pending = mock { on { status }.thenReturn(UsdtTransferStatus.PENDING) } + val histories = listOf( + Result.success(null), + Result.success(pending), + Result.failure(UsdtException.Storage("unreadable history")), + ) + for (history in histories) { + stored = null + val repo = repo() + stubStoredTransfer(history) + stubSend(Result.failure(UsdtException.QuoteExpired())) + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + assertTrue(repo.send(quote).isFailure) + val replacement = repo.prepare( + context(), + quote.copy(id = "replacement"), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ) + assertEquals(history.isSuccess && history.getOrNull() == null, replacement.isSuccess) + } + } + + @Test + fun `started payment remains bound after restart before reconciliation`() = test { + val original = repo() + original.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + stubSend(Result.success(mock())) + original.send(quote).getOrThrow() + + val restarted = repo() + assertTrue(restarted.hasBinding(request.id)) + assertFalse(restarted.hasBinding(request.id.copy(paymentRequestId = "different-request"))) + assertFalse(restarted.hasBinding(request.id.copy(counterparty = identity))) + assertFalse(restarted.hasBinding(request.id.copy(billingPeriodStartsAt = clock.now().toString()))) + } + + @Test + fun `wallet backup restores contact attempts with omitted optional fields`() = test { + val fixture = """ + {"version":1,"createdAt":1,"transfers":[],"paykitPaymentState":{ + "subscriptions":{},"pendingProofs":[],"usdt":{"attempts":[{ + "quoteId":"quote","wallet":"$address","identity":"$identity","contact":"$contact", + "proofQueued":false,"paymentStarted":true + }],"receipts":[]} + }} + """.trimIndent() + val walletBackup = json.decodeFromString(fixture) + val repo = repo() + repo.restoreBackup(requireNotNull(walletBackup.paykitPaymentState?.usdt)) + repo.reconcile().getOrThrow() + val expected = PaykitUsdtAttempt("quote", address, identity, contact, paymentStarted = true) + assertEquals(listOf(expected), repo.attempts.value) + assertEquals(listOf(expected), repo().backupSnapshot().attempts) + } + + @Test + fun `restored started payment is released after confirming no local core history`() = test { + val original = repo() + original.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + val unstartedState = stored + stubSend(Result.success(mock())) + original.send(quote).getOrThrow() + val backup = original.backupSnapshot() + for (localState in listOf(null, unstartedState)) { + stored = localState + val restored = repo() + restored.restoreBackup(backup) + restored.reconcile().getOrThrow() + assertEquals(setOf(request.id), restored.resumableRequests.value) + assertTrue( + restored.prepare( + context(), + quote.copy(id = "replacement"), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ).isSuccess + ) + assertFalse(restored.backupSnapshot().attempts.single().paymentStarted) + } + } + + @Test + fun `cancelled send allows replacement only when its execution journal confirms no submission`() = test { + val pending = mock { on { status }.thenReturn(UsdtTransferStatus.PENDING) } + val histories = listOf( + Result.success(null), + Result.success(pending), + Result.failure(UsdtException.Storage("unreadable history")), + ) + for (history in histories) { + stored = null + val repo = repo() + stubStoredTransfer(history) + stubSend(Result.success(mock())) + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + val sending = CompletableDeferred() + var returned = false + val job = launch { + repo.send(quote) { + sending.complete(Unit) + awaitCancellation() + } + returned = true + } + sending.await() + job.cancelAndJoin() + assertFalse(returned) + val canReplace = history.isSuccess && history.getOrNull() == null + assertEquals(!canReplace, repo().backupSnapshot().attempts.single().paymentStarted) + val replacement = repo().prepare( + context(), + quote.copy(id = "replacement"), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()), + ) + assertEquals(canReplace, replacement.isSuccess) + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt new file mode 100644 index 0000000000..00a6a7154c --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -0,0 +1,868 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentProof +import com.synonym.bitkitcore.UsdtPaymentProofBinding +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.UsdtVerifiedPayment +import com.synonym.bitkitcore.UsdtWallet +import com.synonym.bitkitcore.usdtParsePaymentRequest +import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.ConversionRate +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentConversionQuoteRecord +import com.synonym.paykit.PaymentDeadline +import com.synonym.paykit.PaymentProofRecord +import com.synonym.paykit.PaymentReference +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PubkyIdentityCapability +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing +import to.bitkit.models.PaykitUsdt +import to.bitkit.models.PaykitUsdtStateBackup +import to.bitkit.services.PaykitSdkOperationLock.Priority +import to.bitkit.services.PaykitSdkService +import to.bitkit.services.UsdtService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import to.bitkit.viewmodels.ContactPaymentContext +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Suppress("LargeClass") +class PaykitUsdtPaymentRepoTest : BaseUnitTest() { + @Test + fun `inactive missing and terminal proof free transfers release their rail`() = test { + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + stubSend(Result.success(mock())) + repo.send(quote) {}.getOrThrow() + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + assertFalse(repo.attempts.value.single().paymentStarted) + for (status in listOf(UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED)) { + stubStoredTransfer(Result.success(mock { on { this.status }.thenReturn(status) })) + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + assertFalse(repo.attempts.value.single().paymentStarted) + } + stubStoredTransfer(Result.success(null)) + repo.reconcile().getOrThrow() + assertFalse(repo.hasBinding(request.id)) + } + + @Test + fun `unparseable proof period does not block later valid receipts`() = test { + val malformed = receivedRequest() + whenever(malformed.paymentProofs.single().billingPeriod) + .thenReturn(BillingPeriod("not-an-instant", "2027-02-01T08:00:00Z")) + val valid = receivedRequest("550e8400-e29b-41d4-a716-446655440001") + whenever(sdk.paymentRequests()).thenReturn(listOf(malformed, valid)) + stubVerification( + Result.success(UsdtVerifiedPayment("payment", "transfer", "sender", address, 50_000u, 1_800_000_000u)) + ) + val repo = repo() + repo.reconcile().getOrThrow() + assertEquals(valid.paymentRequestId, repo.receipts.value.single().requestId.paymentRequestId) + verify(wallet, times(1)).verifyPaymentProof(any(), any()) + } + + private val identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val address = "0x1111111111111111111111111111111111111111" + private val keychain: Keychain = mock() + private val sdk: PaykitSdkService = mock() + private val proofs: PaykitPaymentProofRepo = mock() + private val usdt: UsdtRepo = mock() + private val service: UsdtService = mock() + private val wallet: UsdtWallet = mock() + private val clock = object : Clock { override fun now() = Instant.fromEpochSeconds(1_800_000_000) } + private var stored: String? = null + private lateinit var core: MockedStatic<*> + private val request = PaykitPaymentRequest( + paymentRequestId = "550e8400-e29b-41d4-a716-446655440000", + counterparty = contact, + amount = PaykitAmount(PaykitAsset.USD, 5u), + paymentReference = "payment-reference", + pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1")))), + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue), + ) + private val quote = UsdtQuote( + id = "quote", + bridgeProvider = null, + recipient = address, + destination = UsdtDestination.ARBITRUM, + amount = 50_000u, + receivedAmount = 50_000u, + maximumFee = 100u, + expiresAt = 1_800_000_100u, + ) + private val amount = PaykitAmount(PaykitAsset.USDT, 50_000u) + + @Test + fun `receipt status belongs only to the same request and proof`() { + val request = request.copy(paymentProofEventId = "proof") + val receipt = PaykitUsdtReceipt( + wallet = address, + identity = identity, + requestId = request.id, + paymentId = "payment", + proofEventId = "proof", + verified = true, + transferId = "transfer", + amount = amount, + receivedAtMillis = 0, + underpaid = false, + afterExpiry = false, + ) + assertTrue(receipt.matches(request)) + assertFalse(receipt.matches(request.copy(paymentProofEventId = "replacement-proof"))) + assertFalse(receipt.matches(request.copy(paymentRequestId = "different-request"))) + } + + @Before + fun setUp() = test { + core = Mockito.mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")) + core.`when` { usdtParsePaymentRequest(address) } + .thenReturn(UsdtPaymentRequest(address, null, null)) + val endpoint = PaykitUsdt.endpoint(address) + whenever(keychain.loadString(any())).thenAnswer { stored } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { stored = it.getArgument(1) } + whenever( + sdk.identityStatus(any()) + ).thenReturn(IdentityStatus(identity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.paymentRequests()).thenReturn(emptyList()) + whenever(usdt.paymentEndpoint()).thenReturn(Result.success(endpoint)) + whenever( + service.paymentProof(any(), any()) + ).thenReturn(UsdtPaymentProof("42161", "0x" + "a".repeat(64), "0", "0x" + "b".repeat(130))) + whenever(service.wallet()).thenReturn(wallet) + whenever(usdt.operation(any())).doSuspendableAnswer { + val block = it.getArgument Any?>(0) + runCatching { block(service) } + } + stubStoredTransfer(Result.success(null)) + } + + @After + fun tearDown() { if (::core.isInitialized) core.close() } + + private fun stubStoredTransfer(result: Result) { + result.getOrNull()?.let { whenever(it.id).thenReturn(quote.id) } + doAnswer { result.getOrThrow()?.let(::listOf).orEmpty() }.whenever(wallet).history() + } + + private suspend fun stubVerification( + first: Result, + second: Result? = null, + ) { + val results = listOfNotNull(first, second) + var index = 0 + whenever(wallet.verifyPaymentProof(any(), any())).doSuspendableAnswer { + results[(index++).coerceAtMost(results.lastIndex)].getOrThrow() + } + } + + private suspend fun stubSend(result: Result) { + whenever(usdt.send(any(), anyOrNull(), any())).doSuspendableAnswer { invocation -> + runSuspendCatching { + invocation.getArgument Unit>(2).invoke() + result.getOrThrow() + } + } + } + + private fun repo() = PaykitUsdtPaymentRepo(testDispatcher, keychain, sdk, proofs, usdt, clock) + + @Test + fun `expired quotes fail before waiting for payment state or identity`() = test { + val repo = repo() + for (remaining in listOf(0uL, 5uL)) { + val result = repo.prepare( + context(), + quote.copy(expiresAt = clock.now().epochSeconds.toULong() + remaining), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()), + ) + assertTrue(result.exceptionOrNull() is UsdtException.QuoteExpired) + } + verify(sdk, never()).identityStatus(any()) + verify(keychain, never()).upsertString(any(), any()) + } + + @Test + fun `receipt verification permits preparation and preserves concurrent attempts`() = test { + val verifying = CompletableDeferred() + val release = CompletableDeferred() + val record = receivedRequest() + whenever(sdk.paymentRequests()).thenReturn(listOf(record)) + whenever(wallet.verifyPaymentProof(any(), any())).doSuspendableAnswer { + verifying.complete(Unit) + release.await() + UsdtVerifiedPayment("payment", "transfer", "sender", address, 50_000u, 1_800_000_000u) + } + val repo = repo() + val reconciliation = launch { repo.reconcile().getOrThrow() } + verifying.await() + val preparation = async { + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + } + try { + assertTrue(preparation.isCompleted) + } finally { + release.complete(Unit) + } + preparation.await() + reconciliation.join() + assertEquals(quote.id, repo.backupSnapshot().attempts.single().quoteId) + assertEquals("payment", repo.receipts.value.single().paymentId) + verify(sdk).identityStatus(Priority.Interactive) + } + + @Test + fun `stale transfer observations cannot reopen an attempt that started sending`() = test { + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + val observing = CompletableDeferred() + val release = CompletableDeferred() + whenever(usdt.operation(any())).doSuspendableAnswer { + observing.complete(Unit) + release.await() + Result.success(null) + } + val reconciliation = launch { repo.reconcile().getOrThrow() } + observing.await() + stubSend(Result.success(mock())) + val send = async { repo.send(quote).getOrThrow() } + try { + assertTrue(send.isCompleted) + } finally { + release.complete(Unit) + } + send.await() + reconciliation.join() + assertTrue(repo.attempts.value.single().paymentStarted) + assertTrue(repo.hasBinding(request.id)) + assertTrue(repo.resumableRequests.value.isEmpty()) + } + + @Test + fun `restore during proof generation discards the previous reconciliation`() = test { + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + val backup = repo.backupSnapshot() + stubStoredTransfer(Result.success(mock { on { status }.thenReturn(UsdtTransferStatus.CONFIRMED) })) + val generating = CompletableDeferred() + val release = CompletableDeferred() + whenever(service.paymentProof(any(), any())).doSuspendableAnswer { + generating.complete(Unit) + release.await() + UsdtPaymentProof("42161", "transaction", "0", "signature") + } + val reconciliation = launch { repo.reconcile().getOrThrow() } + generating.await() + val restore = async { repo.restoreBackup(backup) } + try { + assertTrue(restore.isCompleted) + } finally { + release.complete(Unit) + } + restore.await() + reconciliation.join() + assertEquals(backup, repo.backupSnapshot()) + assertTrue(repo.resumableRequests.value.isEmpty()) + verify(proofs, never()).submit(any()) + } + + @Test + fun `restore during receipt verification discards stale verified evidence`() = test { + val verifying = CompletableDeferred() + val release = CompletableDeferred() + val record = receivedRequest() + whenever(sdk.paymentRequests()).thenReturn(listOf(record)) + whenever(wallet.verifyPaymentProof(any(), any())).doSuspendableAnswer { + verifying.complete(Unit) + release.await() + UsdtVerifiedPayment("payment", "transfer", "sender", address, 50_000u, 1_800_000_000u) + } + val repo = repo() + val reconciliation = launch { repo.reconcile().getOrThrow() } + verifying.await() + val backup = PaykitUsdtStateBackup(emptyList(), emptyList()) + val restore = async { repo.restoreBackup(backup) } + try { + assertTrue(restore.isCompleted) + } finally { + release.complete(Unit) + } + restore.await() + reconciliation.join() + assertEquals(backup, repo.backupSnapshot()) + assertTrue(repo.receipts.value.isEmpty()) + } + + @Test + fun `ownership changed during context lookup cannot be replaced by stale reconciliation`() = test { + val reading = CompletableDeferred() + val release = CompletableDeferred() + whenever(sdk.identityStatus(Priority.Ordered)).doSuspendableAnswer { + reading.complete(Unit) + release.await() + IdentityStatus(identity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + whenever(sdk.identityStatus(Priority.Interactive)) + .thenReturn(IdentityStatus(contact, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val repo = repo() + val reconciliation = launch { repo.reconcile().getOrThrow() } + reading.await() + val preparation = async { + repo.prepare(context().copy(incomingPaymentRequest = null), quote, amount, null).getOrThrow() + } + try { + assertTrue(preparation.isCompleted) + } finally { + release.complete(Unit) + } + preparation.await() + reconciliation.join() + assertEquals(contact, repo.attempts.value.single().identity) + verify(sdk, never()).paymentRequests() + } + + private fun context(request: PaykitPaymentRequest = this.request) = + ContactPaymentContext( + contact, + privatePaymentContext = PrivatePaykitPaymentContext( + mapOf(MethodId.UsdtArbitrum.rawValue to "bitkit"), + null + ), + incomingPaymentRequest = request, + endpoints = listOf(PaykitUsdt.endpoint(address)) + ) + + @Test + fun `cancellation protection reads durable started state and scopes wallet and identity`() = test { + val period = PaykitBillingPeriod(clock.now(), clock.now() + Duration.parse("1d")) + val recurring = request.copy(billingPeriod = period) + val subscription = PaykitSubscriptionId(request.paymentRequestId, contact) + val repo = repo() + repo.prepare( + context(recurring), + quote, + amount, + recurring.payment(MethodId.UsdtArbitrum, clock.now()) + ).getOrThrow() + assertTrue(repo.protectedRequestIdsForSubscriptionCancellation(identity, subscription).getOrThrow().isEmpty()) + stubSend(Result.success(mock())) + val deadline = clock.now() + Duration.parse("1h") + repo.send(quote, deadline) { + assertEquals( + setOf(recurring.id), + repo().protectedRequestIdsForSubscriptionCancellation(identity, subscription).getOrThrow(), + ) + }.getOrThrow() + verify(usdt).send(eq(quote), eq(deadline), any()) + val restarted = repo() + restarted.prepare( + context(recurring), + quote, + amount, + recurring.payment(MethodId.UsdtArbitrum, clock.now()), + ).getOrThrow() + assertTrue(restarted.attempts.value.single().paymentStarted) + assertEquals( + setOf(recurring.id), + restarted.protectedRequestIdsForSubscriptionCancellation(identity, subscription).getOrThrow() + ) + assertTrue( + restarted.protectedRequestIdsForSubscriptionCancellation(contact, subscription).getOrThrow().isEmpty() + ) + val otherWallet = "0x2222222222222222222222222222222222222222" + core.`when` { usdtParsePaymentRequest(otherWallet) } + .thenReturn(UsdtPaymentRequest(otherWallet, null, null)) + val otherEndpoint = PaykitUsdt.endpoint(otherWallet) + whenever(usdt.paymentEndpoint()).thenReturn(Result.success(otherEndpoint)) + assertTrue( + restarted.protectedRequestIdsForSubscriptionCancellation(identity, subscription).getOrThrow().isEmpty() + ) + val ownEndpoint = PaykitUsdt.endpoint(address) + whenever(usdt.paymentEndpoint()).thenReturn(Result.success(ownEndpoint)) + stubStoredTransfer( + Result.success( + mock { + on { status }.thenReturn(UsdtTransferStatus.FAILED) + } + ) + ) + assertTrue( + restarted.protectedRequestIdsForSubscriptionCancellation(identity, subscription).getOrThrow().isEmpty() + ) + } + + @Test + fun `unreadable persistence prevents sending and is preserved`() = test { + stored = "unreadable" + assertTrue(repo().hasBinding(request.id)) + assertTrue( + repo().prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).isFailure + ) + assertEquals("unreadable", stored) + } + + @Test + fun `preparation requires the approved amount recipient and accepted endpoint`() = test { + val repo = repo() + assertTrue( + repo.prepare( + context(), + quote.copy(amount = 49_999u), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ).isFailure + ) + assertTrue( + repo.prepare( + context(), + quote.copy(recipient = "0x2222222222222222222222222222222222222222"), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ).isFailure + ) + assertTrue( + repo.prepare( + context(request.copy(acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue))), + quote, + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ).isFailure + ) + assertEquals(null, stored) + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + } + + @Test + fun `proof delivery can retry after restart without replacing the payment`() = test { + repo().prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + val confirmed = mock { + on { status }.thenReturn(UsdtTransferStatus.CONFIRMED) + on { txHash }.thenReturn("transaction") + } + stubStoredTransfer(Result.success(confirmed)) + whenever(proofs.submit(any())).thenReturn(Result.success(false), Result.success(true)) + val first = repo() + first.reconcile().getOrThrow() + assertEquals(false, first.attempts.value.single().proofQueued) + val restarted = repo() + restarted.reconcile().getOrThrow() + assertEquals(true, restarted.attempts.value.single().proofQueued) + assertTrue( + restarted.prepare( + context(), + quote.copy(id = "replacement"), + amount, + request.payment(MethodId.UsdtArbitrum, clock.now()) + ).isFailure + ) + } + + @Test + fun `failed proof generation or submission does not block later attempts or receipts`() = test { + for (generationFails in listOf(true, false)) { + stored = null + val other = request.copy(paymentRequestId = "550e8400-e29b-41d4-a716-446655440001") + val otherQuote = quote.copy(id = "other-quote") + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + repo.prepare(context(other), otherQuote, amount, other.payment(MethodId.UsdtArbitrum, clock.now())) + .getOrThrow() + val transfers = listOf(quote.id, otherQuote.id).map { transferId -> + mock { + on { id }.thenReturn(transferId) + on { status }.thenReturn(UsdtTransferStatus.CONFIRMED) + } + } + doAnswer { transfers }.whenever(wallet).history() + whenever(service.paymentProof(any(), any())).doSuspendableAnswer { + if (generationFails && it.getArgument(0) == quote.id) throw AppError("proof unavailable") + UsdtPaymentProof("42161", "0x" + "a".repeat(64), "0", "0x" + "b".repeat(130)) + } + whenever(proofs.submit(any())).doSuspendableAnswer { + val pending = it.getArgument(0) + if (!generationFails && pending.requestId == request.id) { + Result.failure(AppError("proof rejected")) + } else { + Result.success(true) + } + } + val received = receivedRequest("550e8400-e29b-41d4-a716-446655440002") + whenever(sdk.paymentRequests()).thenReturn(listOf(received)) + stubVerification( + Result.success( + UsdtVerifiedPayment( + "42161:transaction:0", + "transaction:0", + "sender", + address, + 50_000u, + 1_800_000_000u + ) + ) + ) + + repeat(2) { + repo.reconcile().getOrThrow() + assertFalse(repo.attempts.value.single { it.quoteId == quote.id }.proofQueued) + assertTrue(repo.attempts.value.single { it.quoteId == otherQuote.id }.proofQueued) + assertEquals(1, repo.receipts.value.size) + assertTrue(repo.hasBinding(request.id)) + } + } + } + + @Test + fun `proof cancellation stops reconciliation`() = test { + val repo = repo() + repo.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + stubStoredTransfer(Result.success(mock { on { status }.thenReturn(UsdtTransferStatus.CONFIRMED) })) + whenever(service.paymentProof(any(), any())).thenThrow(CancellationException("cancelled")) + + assertFailsWith { repo.reconcile() } + verify(sdk, times(0)).paymentRequests() + assertFalse(repo.attempts.value.single().proofQueued) + assertTrue(repo.hasBinding(request.id)) + } + + @Test + fun `receipt verification uses actual funds and claims each payment once`() = test { + val record = receivedRequest() + val second = receivedRequest("550e8400-e29b-41d4-a716-446655440001") + whenever(sdk.paymentRequests()).thenReturn(listOf(record, second)) + val payment = UsdtVerifiedPayment( + "42161:transaction:0", + "transaction:0", + "sender", + address, + 49_999u, + 1_800_000_000u + ) + stubVerification(Result.success(payment)) + val repo = repo() + repo.reconcile().getOrThrow() + assertEquals(1, repo.receipts.value.size) + assertEquals(49_999uL, repo.receipts.value.single().amount.atomic) + assertEquals(false, repo.receipts.value.single().satisfied) + repo.reconcile().getOrThrow() + assertEquals(1, repo.receipts.value.size) + assertEquals(quote.amount - 1u, repo.receipts.value.single().amount.atomic) + } + + @Test + fun `unchanged receipt verification preserves backup state after restart`() = test { + val records = listOf(receivedRequest(), receivedRequest("550e8400-e29b-41d4-a716-446655440001")) + whenever(sdk.paymentRequests()).thenReturn(records) + val payment = UsdtVerifiedPayment( + "42161:transaction:0", + "transaction:0", + "sender", + address, + 50_000u, + 1_800_000_000u, + ) + val secondPayment = payment.copy(paymentId = "42161:transaction:1", transferId = "transaction:1") + stubVerification(Result.success(payment), Result.success(secondPayment)) + val repo = repo() + repo.reconcile().getOrThrow() + val receipts = repo.receipts.value + val persisted = stored + assertEquals(2, receipts.size) + assertEquals(2L, repo.backupStateVersion.value) + + for (current in listOf(repo, repo())) { + val version = current.backupStateVersion.value + stubVerification(Result.success(payment), Result.success(secondPayment)) + current.reconcile().getOrThrow() + assertEquals(receipts, current.receipts.value) + assertEquals(version, current.backupStateVersion.value) + assertEquals(persisted, stored) + } + verify(keychain, times(2)).upsertString(any(), any()) + verify(wallet, times(6)).verifyPaymentProof(any(), any()) + } + + @Test + fun `recurring receipt verifies the selected quote and verbatim proof period`() = test { + val period = BillingPeriod("2027-01-01T08:00:00.000Z", "2027-02-01T08:00:00.000Z") + val quoteId = "650e8400-e29b-41d4-a716-446655440000" + val base = receivedRequest() + val submission = base.paymentProofs.single() + whenever(submission.billingPeriod).thenReturn(period) + whenever(submission.conversionQuoteId).thenReturn(quoteId) + val record = base.copy( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + terms = requireNotNull(base.terms).copy( + recurrence = PaymentRequestRecurrence(1u, "month", period.startsAt, period.startsAt, null), + conversion = PaymentConversion.PerPeriod, + ), + conversionQuotes = listOf( + PaymentConversionQuoteRecord( + quoteId, + period, + listOf(ConversionRate("usdt", "1")), + period.startsAt, + period.endsAt, + null, + ) + ), + ) + whenever(sdk.paymentRequests()).thenReturn(listOf(record)) + stubVerification( + Result.success( + UsdtVerifiedPayment("42161:transaction:0", "transaction:0", "sender", address, 50_000u, 1_800_000_000u) + ) + ) + val unverified = requireNotNull(record.toPaykitSubscription()) + assertTrue(unverified.paidPeriods.isEmpty()) + assertEquals(1, unverified.receivedPaymentRequests().size) + val repo = repo() + repo.reconcile().getOrThrow() + val receipts = repo.verifiedReceipts(identity).getOrThrow() + assertEquals(1, record.toPaykitSubscription(verifiedUsdtReceipts = receipts)?.paidPeriods?.size) + assertUnsatisfiedReceiptsDoNotPayPeriod(record, receipts.single()) + val binding = argumentCaptor() + verify(wallet).verifyPaymentProof(binding.capture(), any()) + assertEquals(period.startsAt, binding.firstValue.periodStartsAt) + assertEquals(period.endsAt, binding.firstValue.periodEndsAt) + assertEquals(quoteId, binding.firstValue.conversionQuoteId) + assertTrue(repo.receipts.value.single().satisfied) + assertEquals( + Instant.parse(period.startsAt), + repo.receipts.value.single().requestId.billingPeriodStartsAt?.let(Instant::parse) + ) + } + + private fun assertUnsatisfiedReceiptsDoNotPayPeriod(record: PaymentRequestRecord, receipt: PaykitUsdtReceipt) { + for (invalidReceipt in listOf( + receipt.copy(underpaid = true), + receipt.copy(afterExpiry = true), + receipt.copy(verified = false), + receipt.copy(proofEventId = "unrelated") + )) { + assertTrue( + requireNotNull( + record.toPaykitSubscription(verifiedUsdtReceipts = listOf(invalidReceipt)) + ).paidPeriods.isEmpty() + ) + } + } + + @Test + fun `receipt status survives restart and reflects canonical execution and payment deadline`() = test { + val record = receivedRequest().let { + it.copy( + terms = it.terms?.copy( + paymentDeadline = PaymentDeadline.At(clock.now().toString()) + ) + ) + } + whenever(sdk.paymentRequests()).thenReturn(listOf(record)) + val paid = UsdtVerifiedPayment( + "42161:transaction:0", + "transaction:0", + "sender", + address, + 50_000u, + 1_800_000_000u + ) + stubVerification(Result.success(paid)) + repo().reconcile().getOrThrow() + val restarted = repo() + restarted.reconcile().getOrThrow() + assertTrue(restarted.receipts.value.single().satisfied) + assertEquals(0L, restarted.backupStateVersion.value) + stubVerification(Result.failure(UsdtException.NetworkUnavailable())) + assertTrue(restarted.reconcile().isFailure) + assertTrue(restarted.receipts.value.single().verified) + assertEquals(0L, restarted.backupStateVersion.value) + stubVerification(Result.success(null)) + restarted.reconcile().getOrThrow() + assertEquals(false, restarted.receipts.value.single().verified) + assertEquals(1L, restarted.backupStateVersion.value) + stubVerification(Result.success(paid.copy(timestamp = paid.timestamp + 1u))) + restarted.reconcile().getOrThrow() + assertTrue(restarted.receipts.value.single().verified) + assertTrue(restarted.receipts.value.single().afterExpiry) + assertEquals(false, restarted.receipts.value.single().satisfied) + assertEquals(2L, restarted.backupStateVersion.value) + } + + @Test + fun `proof envelope includes the application id at the UTF-8 message boundary`() { + val binding = PaykitUsdtPaymentRepo.binding(request, identity, "bitkit", null, null) + // The rc63 envelope and largest ERC-20 receipt proof reserve 692 bytes before the reference. + val exactLimit = binding.copy(paymentReference = "é".repeat(154)) + PaykitUsdtPaymentRepo.validateProofSize(exactLimit) + assertFailsWith { + PaykitUsdtPaymentRepo.validateProofSize( + exactLimit.copy(paymentReference = exactLimit.paymentReference + "x") + ) + } + } + + @Test + fun `proof size is bounded before the payment is prepared`() = test { + val binding = PaykitUsdtPaymentRepo.binding(request, identity, "bitkit", null, request.billingPeriod?.sdkValue) + PaykitUsdtPaymentRepo.validateProofSize(binding) + val oversized = context(request.copy(paymentReference = "é".repeat(256))) + assertTrue( + repo().prepare(oversized, quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).isFailure + ) + assertEquals(null, stored) + } + + @Test + fun `invalid evidence does not prevent another request from completing`() = test { + val records = listOf(receivedRequest(), receivedRequest("550e8400-e29b-41d4-a716-446655440001")) + whenever(sdk.paymentRequests()).thenReturn(records) + val payment = UsdtVerifiedPayment( + "42161:transaction:0", + "transaction:0", + "sender", + address, + 50_000u, + 1_800_000_000u + ) + stubVerification( + Result.failure(UsdtException.InvalidPaymentProof()), + Result.success(payment) + ) + val repo = repo() + repo.reconcile().getOrThrow() + assertEquals(records.last().paymentRequestId, repo.receipts.value.single().requestId.paymentRequestId) + assertEquals(true, repo.receipts.value.single().satisfied) + } + + private fun receivedRequest(id: String = request.paymentRequestId): PaymentRequestRecord { + val json = Json.encodeToString( + PaykitUsdtProof(UsdtPaymentProof("42161", "0x" + "a".repeat(64), "0", "0x" + "b".repeat(130))) + ) + val proof = mock { on { exportText() }.thenReturn(json) } + val submission = mock { + on { eventId }.thenReturn("proof-$id") + on { billingPeriod }.thenReturn(null) + on { paymentAppId }.thenReturn("bitkit") + on { paymentEndpointIdentifier }.thenReturn(MethodId.UsdtArbitrum.rawValue) + on { this.proof }.thenReturn(proof) + } + val reference = mock { on { exportText() }.thenReturn("payment-reference") } + return PaymentRequestRecord( + counterparty = contact, paymentRequestId = id, + localRole = PaymentRequestLocalRole.PAYEE, state = PaymentRequestLifecycleState.PROOF_SUBMITTED, + proposalStreamItemId = 1u, proposalOutboundMessageId = null, proposalOutboundStatus = null, + proposalEventId = "proposal", + proposalAppId = "bitkit", + payerAppId = "bitkit", + executionClaimAppId = "bitkit", + terms = PaymentRequestTerms( + PaymentRequestAmount("0.05", "usd"), + reference, + null, + null, + listOf(MethodId.UsdtArbitrum.rawValue), + null, "bitkit", + PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1"))), + null, + mock() + ), + acceptedEventId = "accepted", acceptedOutboundStatus = null, + rejectedEventId = null, rejectedOutboundStatus = null, + canceledEventId = null, canceledOutboundStatus = null, + paymentProofs = listOf( + submission + ), + conversionQuotes = emptyList(), + lastStreamItemId = 2u, + lastOutboundMessageId = null, lastOutboundStatus = null, + lastEventAt = "2027-01-15T08:00:00Z", invalidReason = null, + ) + } + + @Test + fun `both request participants construct the same reference`() { + val outgoing = request.copy(counterparty = identity, direction = PaykitPaymentRequestDirection.Outgoing) + val reference = PaykitUsdtPaymentRepo.binding( + request, + identity, + "bitkit", + null, + request.billingPeriod?.sdkValue + ) + assertEquals( + reference, + PaykitUsdtPaymentRepo.binding(outgoing, contact, "bitkit", null, outgoing.billingPeriod?.sdkValue) + ) + assertTrue( + reference != PaykitUsdtPaymentRepo.binding( + request.copy(paymentReference = "other"), identity, "bitkit", null, null + ) + ) + } + + @Test + fun `endpoint requires a direct address and the pinned network and token`() { + val endpoint = PaykitUsdt.endpoint(address) + assertEquals(address, PaykitUsdt.address(endpoint.rawPayload)) + for (invalid in listOf( + endpoint.rawPayload.replace("42161", "1"), + endpoint.rawPayload.replace(PaykitUsdt.TOKEN, address), + endpoint.rawPayload.replace("\"42161\"", "42161"), + endpoint.rawPayload.replace(address, endpoint.paymentRequest) + )) { + assertEquals(null, PaykitUsdt.address(invalid)) + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index d6f59fbdaa..36ec8f8abd 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4,6 +4,8 @@ import android.app.Activity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.usdtParsePaymentRequest import com.synonym.paykit.ContactRecord import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerHandshakeReport @@ -37,6 +39,7 @@ import kotlinx.coroutines.withContext import org.junit.After import org.junit.Before import org.junit.Test +import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -58,7 +61,11 @@ import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.UsdtPreference import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitUsdt import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution @@ -104,10 +111,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val publicPaykitRepo = mock() private val paymentRequestRepo = mock() private val coreService = mock() + private val usdtRepo = mock { + on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) + } private val clock = mock() private val cacheData = MutableStateFlow(PrivatePaykitCacheData()) - private val settingsData = MutableStateFlow(SettingsData()) + private val settingsData = MutableStateFlow(SettingsData(publicPaykitUsdtEnabled = false)) private val lightningState = MutableStateFlow( LightningState(nodeLifecycleState = NodeLifecycleState.Running), ) @@ -117,7 +127,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Before fun setUp() = test { cacheData.value = PrivatePaykitCacheData() - settingsData.value = SettingsData() + settingsData.value = SettingsData(publicPaykitUsdtEnabled = false) whenever(cacheStore.data).thenReturn(cacheData) whenever { cacheStore.update(any()) }.thenAnswer { @@ -309,7 +319,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `enabling sharing reschedules current contacts after pending cleanup skipped preparation`() = test { - settingsData.update { SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) } + settingsData.update { + SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false + ) + } cacheData.update { it.copy(cleanupPending = true, contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) } @@ -348,7 +364,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `handleOnchainActivity skips SDK access when publication is locally unnecessary`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() clearInvocations(paykitSdkService, pubkyService) @@ -385,9 +405,32 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(pubkyService, never()).currentPublicKey() } + @Test + fun `private bitcoin publication survives optional USDT failure but cancellation propagates`() = test { + settingsData.value = SettingsData( + publicPaykitUsdtEnabled = true, + usdtPreference = UsdtPreference.ENABLED, + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(AppError("USDT unavailable"))) + sut.setContactPreparationActive(false) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + val updates = argumentCaptor>() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(updates.capture(), eq(false)) + assertEquals(listOf(MethodId.P2wpkh.rawValue), updates.firstValue.single().reservations.map { it.identifier }) + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(CancellationException())) + assertFailsWith { + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + } + } + @Test fun `prepareSavedContacts publishes private reservations through SDK`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -449,7 +492,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `prepareSavedContacts propagates identity errors and cancellation`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) val failures = listOf(AppError("Identity unavailable"), CancellationException("Cancelled")) var failure: Throwable = failures.first() whenever(paykitSdkService.identityStatus()).thenAnswer { throw failure } @@ -469,7 +516,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `publication checks identity capability and settings after identity lookup`() = test { for (unavailable in listOf("identity", "capability", "sharing", "cleanup")) { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) cacheData.value = PrivatePaykitCacheData() val status = CompletableDeferred() whenever(paykitSdkService.identityStatus()) doSuspendableAnswer { status.await() } @@ -501,7 +552,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `publication rechecks identity after wallet reservation`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) val address = CompletableDeferred() whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)) doSuspendableAnswer { Result.success(address.await()) @@ -562,7 +617,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `explicit publication retries queue failures after its priority window expires`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(clock.now()).thenAnswer { Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds } @@ -711,7 +770,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `unavailable peer retains queued delivery until drained`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(clock.now()).thenAnswer { Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds } @@ -752,7 +815,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `unavailable peer that links during queued delivery retries failed intake`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(clock.now()).thenAnswer { Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds } @@ -805,7 +872,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `unavailable peer that links after identity check failure retries failed intake`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(clock.now()).thenAnswer { Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds } @@ -853,7 +924,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `blocked peer retires an unpublished explicit retry`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.BLOCKED))) try { @@ -1309,7 +1384,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `backgrounding queued retry publication does not block explicit publication`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) val completion = CompletableDeferred() var firstLookup = true @@ -1429,7 +1508,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `transport recovery waits for foreground and preserves background priority`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(paykitSdkService.linkedPeers(any())).thenReturn(emptyList()) val completion = CompletableDeferred() whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).doSuspendableAnswer { @@ -1456,7 +1539,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `profile deletion invalidates paused transport recovery`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(paykitSdkService.linkedPeers(any())).thenReturn(emptyList()) val completion = CompletableDeferred() whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).doSuspendableAnswer { @@ -1563,6 +1650,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `prepareSavedContacts defers reservations while link preparation is unavailable`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -1581,6 +1669,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `private message drain keeps retrying while link is still pending`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -1851,6 +1940,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `prepareSavedContacts includes lightning payment hash in reservation attribution`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = true, publicPaykitOnchainEnabled = false, @@ -1881,6 +1971,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `publication skips contacts without Paykit and cleanup has nothing to withdraw`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2027,6 +2118,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, + usdtRepo = mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, clock = clock, ) for (failureStage in listOf("withdraw", "disable capability")) { @@ -2113,6 +2205,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `disable sharing removes onchain-only private publications from cache`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2130,6 +2223,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `disable sharing reports unavailable endpoint cleanup and retains retry state`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2160,6 +2254,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `enabling sharing supersedes deferred cleanup`() = test { cacheData.value = cacheData.value.copy(cleanupPending = true) settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2174,6 +2269,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `disabled sharing retries cached publications without cleanup marker`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2191,6 +2287,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `disabled publication recovery remains pending when local cleanup fails`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2213,6 +2310,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `cleanup removal marks cleanup pending when private endpoint removal fails`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2239,6 +2337,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `cleanup remains pending until queued clear is delivered`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2448,6 +2547,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `prepareSavedContacts records queued contacts when another contact cannot publish`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2472,6 +2572,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `prepareSavedContacts does not require a cached contact record`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2495,6 +2596,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `prepareSavedContacts does not publish while cleanup is pending`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2514,7 +2616,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `enabling returns before contact linking and repeated preparation is coalesced`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + ) val linkStarted = CompletableDeferred() val resumeLink = CompletableDeferred() whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) doSuspendableAnswer { @@ -2539,9 +2645,40 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `payment option changes requeue an active contact publication`() = test { + settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + ) + val publicationStarted = CompletableDeferred() + val resumePublication = CompletableDeferred() + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), eq(false))) doSuspendableAnswer { + if (!publicationStarted.isCompleted) { + publicationStarted.complete(Unit) + resumePublication.await() + } + privateListDeliveryReport() + } + sut.enableSharingAndPrepareSavedContacts({ listOf(CONTACT_KEY) }).getOrThrow() + runCurrent() + assertTrue(publicationStarted.isCompleted) + sut.refreshKnownSavedContactEndpoints("payment options", requeueActive = true).getOrThrow() + resumePublication.complete(Unit) + runCurrent() + sut.awaitContactPreparation() + verify(paykitSdkService, times(2)).syncPrivatePaymentListsWithReservations(any(), eq(false)) + sut.closeAndClear() + } + @Test fun `profile deletion stops preparation and avoids repeated withdrawal`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) sut.beginProfileDeletion() sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() runCurrent() @@ -2560,7 +2697,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `cleanup stops a stalled preparation before later contacts are visited`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) val linkStarted = CompletableDeferred() val resumeLink = CompletableDeferred() whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) doSuspendableAnswer { @@ -2586,7 +2727,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `preparation drops contacts removed while linking`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) val resumeLink = CompletableDeferred() whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) doSuspendableAnswer { resumeLink.await() @@ -2605,7 +2750,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `unavailable contacts are retried after a cooldown`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } repeat(3) { sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() } @@ -2619,7 +2768,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `transport failures only defer contacts without a handshake after advancement`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) var state: LinkedPeerState? = null var nextState: LinkedPeerState? = null whenever(paykitSdkService.linkedPeers()).thenAnswer { @@ -2656,7 +2809,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `removed contacts do not retain unavailable link cooldown`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } @@ -2715,6 +2872,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + PRIVATE_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), + appId = "bitkit" + ) + ), ), result, ) @@ -2724,6 +2889,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `beginSavedContactPayment resolves before starting local endpoint publication`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2759,6 +2925,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `beginSavedContactPayment runs one endpoint publish per contact at a time`() = test { settingsData.value = SettingsData( + publicPaykitUsdtEnabled = false, sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, @@ -2804,12 +2971,52 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + PRIVATE_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), + appId = "bitkit" + ) + ), ), result, ) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `USDT only private contacts retain their endpoint and list version`() = test { + val address = "0x1111111111111111111111111111111111111111" + Mockito.mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { core -> + core.`when` { usdtParsePaymentRequest(address) } + .thenReturn(UsdtPaymentRequest(address, null, null)) + val endpoint = PaykitUsdt.endpoint(address).copy(appId = "bitkit") + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) + }.thenReturn( + resolution( + PaykitResolvedPaymentEndpoint( + appId = "bitkit", + identifier = endpoint.methodId.rawValue, + payload = endpoint.rawPayload, + ), + version = 7uL, + ) + ) + + val result = assertIs( + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + ) + + assertEquals(listOf(endpoint), result.endpoints) + assertEquals(PaykitUsdt.paymentUri(address), result.paymentRequest) + assertEquals(7uL, result.privatePaymentContext?.paymentListVersion) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + } + @Test fun `beginSavedContactPayment never falls back while linked recovery is pending`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) @@ -2862,6 +3069,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + SERVER_PRIVATE_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(SERVER_PRIVATE_BOLT11), + appId = "bitkit" + ) + ), ), opened, ) @@ -2934,6 +3149,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + PRIVATE_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), + appId = "bitkit" + ) + ), ), result, ) @@ -3084,6 +3307,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + SERVER_PRIVATE_BOLT11, + rawPayload = """{"value":"$SERVER_PRIVATE_BOLT11"}""", + appId = "bitkit" + ) + ), ), result, ) @@ -3121,6 +3352,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + PRIVATE_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), + appId = "bitkit" + ) + ), ), result, ) @@ -3159,6 +3398,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + SERVER_PRIVATE_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(SERVER_PRIVATE_BOLT11), + appId = "bitkit" + ) + ), ), result, ) @@ -3186,7 +3433,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)).copy(paymentRequestId = id) val result = sut.beginPaymentRequest(request).getOrThrow() val context = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), null) - assertEquals(PublicPaykitPaymentResult.Opened(addresses.getValue(id), context), result) + assertEquals( + PublicPaykitPaymentResult.Opened( + addresses.getValue(id), + context, + listOf( + Endpoint( + MethodId.P2wpkh, + addresses.getValue(id), + rawPayload = PublicPaykitRepo.serializePayload(addresses.getValue(id)), + appId = "bitkit" + ) + ) + ), + result + ) sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() sut.releasePrivatePaymentList(CONTACT_KEY, context).getOrThrow() assertEquals(7uL, cacheData.value.contacts.getValue(CONTACT_KEY).consumedPrivatePaymentListVersion) @@ -3236,6 +3497,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val expected = PublicPaykitPaymentResult.Opened( PRIVATE_ADDRESS, PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), null), + endpoints = listOf( + Endpoint( + MethodId.P2wpkh, + PRIVATE_ADDRESS, + rawPayload = PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS), + appId = "bitkit", + ), + ), ) assertEquals(expected, sut.beginPaymentRequest(request).getOrThrow()) @@ -3295,7 +3564,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `backupSnapshot and restoreBackup use SDK backup state`() = test { val backup = "sdk-backup" - whenever(paykitSdkService.exportBackupState()).thenReturn(backup) + whenever(paykitSdkService.exportBackupState(Priority.Interactive)).thenReturn(backup) sut.consumePrivatePaymentList( CONTACT_KEY, PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), @@ -3332,6 +3601,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { walletRepo = walletRepo, publicPaykitRepo = publicPaykitRepo, paymentRequestRepo = paymentRequestRepo, + usdtRepo = usdtRepo, coreService = coreService, clock = clock, ).also { it.setContactPreparationActive(true) } @@ -3376,8 +3646,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = CONTACT_KEY, - amountValue = "0.000025", - amountSats = 2_500uL, + paymentReference = "test-reference", + amount = PaykitAmount(PaykitAsset.BTC, 2_500uL), expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), acceptedPaymentEndpointIdentifiers = acceptedEndpointIdentifiers, ) diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 0c55c000ca..a9d40cd290 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -6,6 +6,7 @@ import com.synonym.bitkitcore.Scanner import com.synonym.paykit.EndpointSyncChange import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.PublicationStatus +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow @@ -25,13 +26,16 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.UsdtPreference import to.bitkit.services.CoreService import to.bitkit.services.PaykitPublicContactPaymentResolution import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock @@ -54,6 +58,9 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val paykitSdkService = mock() private val settingsStore = mock() private val clock = mock() + private val usdtRepo = mock { + on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) + } private val publicKey = MutableStateFlow("pubkyself") private val isRestoringSession = MutableStateFlow(false) @@ -65,7 +72,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Before fun setUp() = test { sut = createRepo() - settingsFlow.value = SettingsData() + settingsFlow.value = SettingsData(publicPaykitUsdtEnabled = false) publicKey.value = "pubkyself" isRestoringSession.value = false walletState.value = WalletState() @@ -89,6 +96,25 @@ class PublicPaykitRepoTest : BaseUnitTest() { PublicPaykitRepo.lightningRouteHintsValidator = null } + @Test + fun `optional USDT endpoint failure preserves bitcoin publication and propagates cancellation`() = test { + walletState.value = WalletState(onchainAddress = "bc1ptest") + settingsFlow.value = SettingsData( + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + publicPaykitUsdtEnabled = true, + usdtPreference = UsdtPreference.ENABLED, + ) + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(AppError("USDT unavailable"))) + sut.syncCurrentPublishedEndpoints().getOrThrow() + val endpoints = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPublicEndpoints(endpoints.capture()) } + assertEquals(listOf(MethodId.P2tr), endpoints.firstValue.map { it.methodId }) + + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(CancellationException())) + assertFailsWith { sut.syncCurrentPublishedEndpoints() } + } + @Test fun `private capability follows sharing preference`() = test { for (enabled in listOf(false, true)) { @@ -149,6 +175,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncPublishedEndpoints creates reusable onchain endpoint when cached address is blank`() = test { settingsFlow.value = SettingsData( + publicPaykitUsdtEnabled = false, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) @@ -171,6 +198,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncPublishedEndpoints does not publish endpoints when app registration fails`() = test { settingsFlow.value = SettingsData( + publicPaykitUsdtEnabled = false, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) @@ -190,6 +218,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { for (priority in listOf(Priority.Ordered, Priority.Interactive)) { clearInvocations(paykitSdkService) settingsFlow.value = SettingsData( + publicPaykitUsdtEnabled = false, publicPaykitBolt11 = "lnbc1old", publicPaykitBolt11PaymentHash = "010203", publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), @@ -269,7 +298,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncCurrentPublishedEndpoints returns NoSupportedEndpoint when endpoint is required`() = test { - settingsFlow.value = SettingsData(publicPaykitOnchainEnabled = false) + settingsFlow.value = SettingsData(publicPaykitUsdtEnabled = false, publicPaykitOnchainEnabled = false) whenever(lightningRepo.canReceive()).thenReturn(false) val error = sut.syncCurrentPublishedEndpoints(requireEndpoint = true).exceptionOrNull() @@ -295,7 +324,20 @@ class PublicPaykitRepoTest : BaseUnitTest() { val result = sut.beginPayment("pubkycontact").getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) + assertEquals( + PublicPaykitPaymentResult.Opened( + PUBLIC_BOLT11, + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + PUBLIC_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(PUBLIC_BOLT11), + appId = "bitkit" + ) + ) + ), + result + ) } @Test @@ -315,7 +357,20 @@ class PublicPaykitRepoTest : BaseUnitTest() { val result = sut.beginPayment("pubkycontact").getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) + assertEquals( + PublicPaykitPaymentResult.Opened( + PUBLIC_BOLT11, + endpoints = listOf( + Endpoint( + MethodId.Bolt11, + PUBLIC_BOLT11, + rawPayload = PublicPaykitRepo.serializePayload(PUBLIC_BOLT11), + appId = "another-wallet" + ) + ) + ), + result + ) } } @@ -356,6 +411,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { walletRepo = walletRepo, lightningRepo = lightningRepo, coreService = coreService, + usdtRepo = usdtRepo, paykitSdkService = paykitSdkService, settingsStore = settingsStore, clock = clock, diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 5692b3ff63..b2c66c6a53 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -45,6 +45,7 @@ import to.bitkit.models.HwWalletVendor import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork +import to.bitkit.models.walletKey import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler @@ -163,18 +164,32 @@ class TrezorRepoTest : BaseUnitTest() { on { this.unlocked }.thenReturn(unlocked) } + /** + * A response shaped like bitkit-core 0.7.0 returns it for an account whose key Core 0.5.18 returned, + * and the store persisted, as [storedKey]: SegWit accounts carry it in `xpubSegwit` next to a + * normalized `xpub`, and Taproot adds a descriptor that must never be stored. + */ private fun mockPublicKeyResponse( - xpub: String, + storedKey: String, path: String, - ) = TrezorPublicKeyResponse( - xpub = xpub, - path = path, - publicKey = "pubkey", - chainCode = "chaincode", - fingerprint = 0u, - depth = 3u, - rootFingerprint = 0u, - ) + ): TrezorPublicKeyResponse { + val purpose = path.split("/").getOrNull(1) + val isSegwit = purpose == "49'" || purpose == "84'" + val descriptor = "tr([00000000/86'/1'/0']$storedKey/<0;1>/*)".takeIf { purpose == "86'" } + val xpubSegwit = if (isSegwit) storedKey else descriptor + return TrezorPublicKeyResponse( + xpub = if (isSegwit) "normalized-$storedKey" else storedKey, + xpubSegwit = xpubSegwit, + descriptor = descriptor, + displayablePublicKey = xpubSegwit ?: storedKey, + path = path, + publicKey = "pubkey", + chainCode = "chaincode", + fingerprint = 0u, + depth = 3u, + rootFingerprint = 0u, + ) + } private fun stubAccountXpubFetch() { whenever { @@ -185,7 +200,7 @@ class TrezorRepoTest : BaseUnitTest() { ) }.thenAnswer { mockPublicKeyResponse( - xpub = "xpub-${it.getArgument(0)}", + storedKey = "xpub-${it.getArgument(0)}", path = it.getArgument(0), ) } @@ -757,7 +772,7 @@ class TrezorRepoTest : BaseUnitTest() { ).thenAnswer { val path = it.getArgument(0) if (path == nativeSegwitPath) { - mockPublicKeyResponse(xpub = "same-native-xpub", path = nativeSegwitPath) + mockPublicKeyResponse(storedKey = "same-native-xpub", path = nativeSegwitPath) } else { throw AppError("xpub failed") } @@ -822,7 +837,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) whenever( trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) - ).thenAnswer { mockPublicKeyResponse(xpub = sharedKey, path = it.getArgument(0)) } + ).thenAnswer { mockPublicKeyResponse(storedKey = sharedKey, path = it.getArgument(0)) } sut = createSut() sut.scan() @@ -853,7 +868,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) whenever( trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) - ).thenAnswer { mockPublicKeyResponse(xpub = sharedKey, path = it.getArgument(0)) } + ).thenAnswer { mockPublicKeyResponse(storedKey = sharedKey, path = it.getArgument(0)) } sut = createSut() sut.scan() @@ -887,7 +902,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) whenever( trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) - ).thenAnswer { mockPublicKeyResponse(xpub = sharedKey, path = it.getArgument(0)) } + ).thenAnswer { mockPublicKeyResponse(storedKey = sharedKey, path = it.getArgument(0)) } sut = createSut() sut.scan() @@ -921,7 +936,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) whenever( trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) - ).thenAnswer { mockPublicKeyResponse(xpub = sharedKey, path = it.getArgument(0)) } + ).thenAnswer { mockPublicKeyResponse(storedKey = sharedKey, path = it.getArgument(0)) } sut = createSut() sut.scan() @@ -1073,7 +1088,7 @@ class TrezorRepoTest : BaseUnitTest() { ).thenAnswer { val path = it.getArgument(0) if (path == nativeSegwitPath) { - mockPublicKeyResponse(xpub = "native-xpub", path = nativeSegwitPath) + mockPublicKeyResponse(storedKey = "native-xpub", path = nativeSegwitPath) } else { throw AppError("xpub failed") } @@ -1095,6 +1110,61 @@ class TrezorRepoTest : BaseUnitTest() { ) } + @Test + fun `connect stores the account keys in the form core 0_5_18 returned them`() = test { + // Core 0.7.0 normalizes `xpub`; storing it would change the wallet key and derived wallet id. + val features = mockFeatures() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR)) + assertEquals( + mapOf( + "legacy" to "xpub-m/44'/1'/0'", + "nestedSegwit" to "xpub-m/49'/1'/0'", + "nativeSegwit" to "xpub-m/84'/1'/0'", + "taproot" to "xpub-m/86'/1'/0'", + ), + captor.firstValue.single().xpubs, + ) + } + + @Test + fun `connect keeps the identity of a device paired before core 0_7_0`() = test { + val pairedBefore = mockKnownDevice( + xpubs = mapOf( + "legacy" to "xpub-m/44'/1'/0'", + "nestedSegwit" to "xpub-m/49'/1'/0'", + "nativeSegwit" to "xpub-m/84'/1'/0'", + "taproot" to "xpub-m/86'/1'/0'", + ), + customLabel = "Savings", + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices(HwWalletVendor.TREZOR)).thenReturn(listOf(pairedBefore)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture(), anyOrNull(), eq(HwWalletVendor.TREZOR)) + val saved = captor.firstValue.single() + assertEquals(pairedBefore.xpubs, saved.xpubs) + assertEquals(pairedBefore.walletKey, saved.walletKey) + assertEquals(pairedBefore.walletId, saved.walletId) + assertEquals("Savings", saved.customLabel) + } + @Test fun `connect preserves stored custom label over stale state label`() = test { val features = mockFeatures() @@ -1212,7 +1282,7 @@ class TrezorRepoTest : BaseUnitTest() { if (nativeSegwitAttempts == 1) { throw TrezorException.DeviceBusy() } - mockPublicKeyResponse(xpub = "native-xpub", path = nativeSegwitPath) + mockPublicKeyResponse(storedKey = "native-xpub", path = nativeSegwitPath) } else { throw AppError("unsupported") } @@ -1282,7 +1352,7 @@ class TrezorRepoTest : BaseUnitTest() { ) ).thenAnswer { if (it.getArgument(0) == nativeSegwitPath) { - mockPublicKeyResponse(xpub = "native-xpub", path = nativeSegwitPath) + mockPublicKeyResponse(storedKey = "native-xpub", path = nativeSegwitPath) } else { throw TrezorException.Timeout() } @@ -2207,7 +2277,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) whenever( trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) - ).thenAnswer { mockPublicKeyResponse(xpub = keptKey, path = it.getArgument(0)) } + ).thenAnswer { mockPublicKeyResponse(storedKey = keptKey, path = it.getArgument(0)) } sut = createSut() sut.scan() sut.connect(DEVICE_ID) @@ -2237,7 +2307,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) whenever( trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) - ).thenAnswer { mockPublicKeyResponse(xpub = forgottenKey, path = it.getArgument(0)) } + ).thenAnswer { mockPublicKeyResponse(storedKey = forgottenKey, path = it.getArgument(0)) } sut = createSut() sut.scan() sut.connect(DEVICE_ID) diff --git a/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt new file mode 100644 index 0000000000..c7ee42e293 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt @@ -0,0 +1,213 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDepositPage +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.UsdtWallet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.data.UsdtPreference +import to.bitkit.services.UsdtService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UsdtPreferencesRepoTest : BaseUnitTest() { + private val settings = MutableStateFlow(SettingsData()) + private val wallet = MutableStateFlow(UsdtWalletState()) + private val settingsStore: SettingsStore = mock { on { data } doReturn settings } + private val usdt: UsdtRepo = mock { on { state } doReturn wallet } + private val repo get() = UsdtPreferencesRepo(settingsStore, usdt, testDispatcher) + + @Before + fun setUp() = test { + whenever(settingsStore.update(any())).thenAnswer { + settings.value = it.getArgument<(SettingsData) -> SettingsData>(0)(settings.value) + Unit + } + whenever(usdt.refresh(history = false, force = true)).thenReturn(Result.success(Unit)) + } + + @Test + fun `missing preference remains undecided and explicit choices round trip`() { + assertEquals(UsdtPreference.UNDECIDED, Json.decodeFromString("{}").usdtPreference) + UsdtPreference.entries.forEach { preference -> + val settings = SettingsData(usdtPreference = preference) + assertEquals(preference, Json.decodeFromString(Json.encodeToString(settings)).usdtPreference) + } + } + + @Test + fun `explicit enable and decline persist separately from payment options`() = test { + repo.setEnabled(true).getOrThrow() + assertEquals(UsdtPreference.ENABLED, settings.value.usdtPreference) + settings.value = SettingsData() + repo.setEnabled(false).getOrThrow() + assertEquals(UsdtPreference.DISABLED, settings.value.usdtPreference) + assertTrue(settings.value.publicPaykitUsdtEnabled) + assertFalse(repo.enabled.first()) + } + + @Test + fun `declining introduction preserves existing funds without trapping the introduction`() = test { + wallet.value = UsdtWalletState(balance = 1u) + repo.setEnabled(false).getOrThrow() + assertEquals(UsdtPreference.DISABLED, settings.value.usdtPreference) + assertTrue(repo.enabled.first()) + assertFalse(repo.canDisable.first()) + } + + @Test + fun `funds or unavailable balance prevent disabling an enabled account`() = test { + settings.value = SettingsData(usdtPreference = UsdtPreference.ENABLED) + for (balance in listOf(null, 1uL)) { + wallet.value = UsdtWalletState(balance = balance) + assertTrue(repo.setEnabled(false).exceptionOrNull() is UsdtDisableError) + assertEquals(UsdtPreference.ENABLED, settings.value.usdtPreference) + } + } + + @Test + fun `refresh failure preserves the enabled preference`() = test { + settings.value = SettingsData(usdtPreference = UsdtPreference.ENABLED) + wallet.value = UsdtWalletState(balance = 0u) + whenever(usdt.refresh(history = false, force = true)).thenReturn(Result.failure(AppError("offline"))) + assertTrue(repo.setEnabled(false).isFailure) + assertEquals(UsdtPreference.ENABLED, settings.value.usdtPreference) + } + + @Test + fun `unsettled outgoing transfers protect visibility and prevent disabling`() { + val unresolved = setOf( + UsdtTransferStatus.PENDING, + UsdtTransferStatus.BRIDGING, + UsdtTransferStatus.BRIDGE_FAILED, + UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, + ) + UsdtTransferStatus.entries.forEach { status -> + val transfer: UsdtTransfer = mock { on { this.status } doReturn status } + val wallet = UsdtWalletState(balance = 0u, transfers = persistentListOf(transfer)) + assertEquals(status in unresolved, wallet.hasFundsOrPendingTransfers, status.name) + assertEquals(status !in unresolved, wallet.canDisable, status.name) + } + } + + @Test + fun `recovered history stays visible until an empty balance is confirmed`() = test { + val transfer: UsdtTransfer = mock { on { status } doReturn UsdtTransferStatus.CONFIRMED } + wallet.value = UsdtWalletState(transfers = persistentListOf(transfer)) + assertTrue(repo.visible.first()) + assertFalse(repo.enabled.first()) + assertFalse(repo.canDisable.first()) + wallet.value = wallet.value.copy(balance = 0u) + assertFalse(repo.visible.first()) + } + + @Test + fun `default off never publishes the USDT receiving option`() { + assertFalse(PublicPaykitRepo.isUsdtPaymentOptionEnabled(SettingsData(), UsdtWalletState())) + assertFalse( + PublicPaykitRepo.isUsdtPaymentOptionEnabled( + SettingsData(usdtPreference = UsdtPreference.DISABLED), + UsdtWalletState() + ) + ) + } + + @Test + fun `restored funds or pending transfers keep a declined wallet enabled without changing consent`() = test { + settings.value = SettingsData(usdtPreference = UsdtPreference.DISABLED) + val pending: UsdtTransfer = mock { on { status } doReturn UsdtTransferStatus.PENDING } + for (state in listOf( + UsdtWalletState(balance = 1u), + UsdtWalletState(balance = 0u, transfers = persistentListOf(pending)), + )) { + wallet.value = state + assertTrue(repo.enabled.first()) + assertFalse(repo.canDisable.first()) + assertTrue(repo.setEnabled(false).isFailure) + assertTrue(PublicPaykitRepo.isUsdtPaymentOptionEnabled(settings.value, state)) + assertEquals(UsdtPreference.DISABLED, settings.value.usdtPreference) + } + wallet.value = UsdtWalletState(balance = 0u) + assertFalse(repo.enabled.first()) + assertTrue(repo.canDisable.first()) + } + + @Test + fun `settled history with an unavailable balance does not enable a declined wallet`() = test { + settings.value = SettingsData(usdtPreference = UsdtPreference.DISABLED) + val settled: UsdtTransfer = mock { on { status } doReturn UsdtTransferStatus.CONFIRMED } + val service: UsdtService = mock() + val coreWallet: UsdtWallet = mock() + whenever(service.wallet()).thenReturn(coreWallet) + whenever(coreWallet.receiveAddress()).thenReturn("wallet-address") + whenever(coreWallet.receiveUri()).thenReturn("wallet-uri") + whenever(coreWallet.history()).thenReturn(listOf(settled)) + whenever(coreWallet.balance()).thenAnswer { throw AppError("offline") } + val usdt = UsdtRepo(service, testDispatcher) + assertTrue(usdt.refresh(history = false).isFailure) + val preferences = UsdtPreferencesRepo(settingsStore, usdt, testDispatcher) + assertEquals(null, usdt.state.value.balance) + assertEquals(1, usdt.state.value.transfers.size) + assertTrue(preferences.visible.first()) + assertFalse(preferences.enabled.first()) + assertFalse(preferences.canDisable.first()) + assertFalse(PublicPaykitRepo.isUsdtPaymentOptionEnabled(settings.value, usdt.state.value)) + } + + @Test + fun `disable check refreshes a previously pending deposit after it settles`() = test { + val service: UsdtService = mock() + val coreWallet: UsdtWallet = mock() + whenever(service.wallet()).thenReturn(coreWallet) + whenever(coreWallet.receiveAddress()).thenReturn("wallet-address") + whenever(coreWallet.receiveUri()).thenReturn("wallet-uri") + whenever(coreWallet.balance()).thenReturn(0u) + whenever(coreWallet.history()).thenReturn(emptyList()) + whenever(coreWallet.refreshTransfers()).thenReturn(emptyList()) + val usdt = UsdtRepo(service, testDispatcher) + usdt.refresh(history = false).getOrThrow() + val pending: UsdtDeposit = mock { + on { id } doReturn "pending-deposit" + on { status } doReturn "refunding" + } + usdt.updateDeposits(listOf(pending)) + assertFalse(usdt.state.value.canDisable) + whenever(pending.status).thenReturn("refunded") + val page: UsdtDepositPage = mock { + on { deposits } doReturn listOf(pending) + on { nextOffset } doReturn null + } + whenever(service.depositHistory(0u)).thenReturn(page) + val preferences = UsdtPreferencesRepo(settingsStore, usdt, testDispatcher) + preferences.checkCanDisable().getOrThrow() + assertTrue(usdt.state.value.canDisable) + assertTrue(usdt.state.value.pendingDepositIds.isEmpty()) + } + + @Test + fun `unused and settled deposit routes do not block disabling`() { + for (status in listOf("waiting", "awaiting_deposit", "expired", "completed", "refunded")) { + val deposit: UsdtDeposit = mock { on { this.status } doReturn status } + assertFalse(deposit.needsSettlement, status) + } + for (status in listOf("confirming", "processing", "failed", "refund_requested", "refunding", "unknown")) { + val deposit: UsdtDeposit = mock { on { this.status } doReturn status } + assertTrue(deposit.needsSettlement, status) + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt new file mode 100644 index 0000000000..a8a1651821 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt @@ -0,0 +1,404 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import com.synonym.bitkitcore.UsdtWallet +import com.synonym.bitkitcore.usdtParseAmount +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.services.UsdtService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class UsdtRepoTest : BaseUnitTest() { + private val service: UsdtService = mock() + private val wallet: UsdtWallet = mock() + private val transfer: UsdtTransfer = mock() + + private fun quote(expiresAt: ULong = ULong.MAX_VALUE): UsdtQuote = mock { + on { this.expiresAt }.thenReturn(expiresAt) + } + + @Test + fun `unused disabled wallet does not refresh but stored recovery and opt in do`() { + val repo = UsdtRepo(service, testDispatcher) + assertEquals(false, repo.shouldRefresh(enabled = false)) + assertTrue(repo.shouldRefresh(enabled = true)) + whenever(service.hasStoredWallet).thenReturn(true) + assertTrue(repo.shouldRefresh(enabled = false)) + verify(service, never()).wallet() + } + + @Test + fun `own address has a specific error for exact and maximum sends`() = test { + val address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn(address) + val repo = UsdtRepo(service, testDispatcher) + val recipient = " ${address.lowercase()} " + assertTrue(repo.quote(recipient, "1", UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtSelfSendError) + assertTrue(repo.quoteMaximum(recipient, UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtSelfSendError) + verify(wallet, never()).quoteTransfer(recipient, 1_000_000u, UsdtDestination.ARBITRUM) + verify(wallet, never()).quoteMaxTransfer(recipient, UsdtDestination.ARBITRUM) + + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when` { usdtParseAmount("1") }.thenReturn(1_000_000u) + val other = "0x1111111111111111111111111111111111111111" + val quote = quote() + whenever(wallet.quoteTransfer(other, 1_000_000u, UsdtDestination.ARBITRUM)).thenReturn(quote) + assertEquals(quote, repo.quote(other, "1", UsdtDestination.ARBITRUM).getOrThrow()) + } + } + + @Test + fun `known unsettled deposits protect the wallet until their updated status is settled`() = test { + val repo = UsdtRepo(service, testDispatcher) + val pending: UsdtDeposit = mock { + on { id }.thenReturn("deposit-one") + on { status }.thenReturn("confirming") + } + val second: UsdtDeposit = mock { + on { id }.thenReturn("deposit-two") + on { status }.thenReturn("refunding") + } + repo.updateDeposits(listOf(pending, second)) + assertEquals(setOf("deposit-one", "deposit-two"), repo.state.value.pendingDepositIds) + assertTrue(repo.state.value.hasFundsOrPendingTransfers) + whenever(pending.status).thenReturn("completed") + repo.updateDeposits(listOf(pending)) + assertEquals(setOf("deposit-two"), repo.state.value.pendingDepositIds) + whenever(second.status).thenReturn("refunded") + repo.updateDeposits(listOf(second)) + assertTrue(repo.state.value.pendingDepositIds.isEmpty()) + } + + @Test + fun `deposit operations finish before wipe clears wallet credentials`() = test { + val started = CompletableDeferred() + val finish = CompletableDeferred() + whenever(service.depositNetworks()).doSuspendableAnswer { + started.complete(Unit) + finish.await() + emptyList() + } + val repo = UsdtRepo(service, testDispatcher) + val deposits = UsdtDepositRepo(repo) + val request = launch { deposits.depositNetworks().getOrThrow() } + started.await() + var credentialsCleared = false + val wipe = launch { repo.wipe { credentialsCleared = true }.getOrThrow() } + runCurrent() + assertEquals(false, credentialsCleared) + verify(service, never()).wipe() + + finish.complete(Unit) + request.join() + wipe.join() + + assertTrue(credentialsCleared) + verify(service).wipe() + } + + @Test + fun `saved activity is published before an offline balance refresh fails`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(listOf(transfer)) + whenever(wallet.balance()).thenThrow(UsdtException.NetworkUnavailable()) + val repo = UsdtRepo(service, testDispatcher) + assertTrue(repo.refresh(true, force = true).isFailure) + assertEquals(listOf(transfer), repo.state.value.transfers) + assertEquals("address", repo.state.value.address) + } + + @Test + fun `history still updates after settlement fails`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList(), listOf(incoming("received"))) + whenever(wallet.balance()).thenReturn(1_000_000uL) + whenever(wallet.refreshTransfers()).thenThrow(UsdtException.NetworkUnavailable()) + whenever(wallet.syncHistory()).thenReturn(true) + val repo = UsdtRepo(service, testDispatcher) + assertTrue(repo.refresh(true, force = true).isFailure) + assertEquals(listOf(incoming("received")), repo.state.value.transfers) + assertTrue(repo.state.value.historyComplete) + verify(wallet).syncHistory() + } + + @Test + fun `throttled settlement does not request history`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.balance()).thenReturn(1_000_000uL) + whenever(wallet.refreshTransfers()).thenThrow(UsdtException.RateLimited()) + val repo = UsdtRepo(service, testDispatcher) + assertTrue(repo.refresh(true, force = true).exceptionOrNull() is UsdtException.RateLimited) + verify(wallet, never()).syncHistory() + } + + @Test + fun `forced refresh reports cooldown instead of accepting cached balance`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.balance()).thenReturn(0uL) + whenever(wallet.refreshTransfers()).thenThrow(UsdtException.RateLimited()) + val repo = UsdtRepo(service, testDispatcher) + assertTrue(repo.refresh(history = false, force = true).isFailure) + assertTrue(repo.refresh(history = false, force = true).exceptionOrNull() is UsdtException.RateLimited) + verify(wallet).balance() + assertTrue(repo.refresh(history = false).isSuccess) + } + + @Test + fun `send presentation defers history without stopping settlement and resumes after dismissal`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.refreshTransfers()).thenReturn(emptyList()) + whenever(wallet.syncHistory()).thenReturn(true) + val balance = CompletableDeferred() + whenever(wallet.balance()).doSuspendableAnswer { balance.await() } + val repo = UsdtRepo(service, testDispatcher) + val refresh = launch { repo.refresh(true, force = true).getOrThrow() } + runCurrent() + repo.isSendPresented = true + balance.complete(1_000_000uL) + refresh.join() + assertEquals(1_000_000uL, repo.state.value.balance) + verify(wallet).refreshTransfers() + verify(wallet, never()).syncHistory() + assertEquals(false, repo.state.value.historyComplete) + + repo.isSendPresented = false + repo.refresh(true, force = true).getOrThrow() + verify(wallet).syncHistory() + assertTrue(repo.state.value.historyComplete) + } + + @Test + fun `send authorizes after queued wallet work and never dispatches expired terms`() = test { + val release = CompletableDeferred() + whenever(service.depositNetworks()).doSuspendableAnswer { + release.await() + emptyList() + } + val repo = UsdtRepo(service, testDispatcher) + val preceding = launch { UsdtDepositRepo(repo).depositNetworks().getOrThrow() } + runCurrent() + val quote = quote() + var authorized = false + var expired = false + val send = launch { + val result = repo.send(quote) { + authorized = true + if (expired) throw PaykitPaymentRequestError.RequestExpired + } + assertEquals(PaykitPaymentRequestError.RequestExpired, result.exceptionOrNull()) + } + runCurrent() + assertEquals(false, authorized) + expired = true + release.complete(Unit) + preceding.join() + send.join() + assertTrue(authorized) + verify(service, never()).send(quote) + } + + @Test + fun `pending submission stays successful when subsequent history is unreadable`() = test { + val quote = quote() + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.history()).thenThrow(UsdtException.Storage("unreadable history")) + val submitted = incoming("submitted").copy( + isIncoming = false, + status = UsdtTransferStatus.PENDING, + txHash = null, + ) + val deadline = Instant.parse("2026-10-12T12:00:00.123456789Z") + whenever(service.send(quote, deadline)).thenReturn(submitted) + val repo = UsdtRepo(service, testDispatcher) + assertEquals(submitted, repo.send(quote, deadline).getOrThrow()) + verify(service).send(quote, deadline) + assertEquals(listOf(submitted), repo.state.value.transfers) + } + + @Test + fun `failed send publishes its durably queued transfer and preserves the error`() = test { + val quote = quote() + val error = UsdtException.BackupUnavailable() + val queued = incoming("queued").copy( + isIncoming = false, + status = UsdtTransferStatus.PENDING, + txHash = null, + ) + whenever(service.send(quote)).thenAnswer { throw error } + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.history()).thenReturn(listOf(queued)) + val repo = UsdtRepo(service, testDispatcher) + + assertEquals(error, repo.send(quote).exceptionOrNull()) + assertEquals(listOf(queued), repo.state.value.transfers) + verify(service).send(quote) + } + + @Test + fun `fee quote expiration while waiting for wallet work prevents submission`() = test { + var now = Instant.fromEpochSeconds(1_800_000_000) + val clock = object : Clock { override fun now() = now } + val release = CompletableDeferred() + whenever(service.depositNetworks()).doSuspendableAnswer { + release.await() + emptyList() + } + val repo = UsdtRepo(service, testDispatcher, clock) + val preceding = launch { UsdtDepositRepo(repo).depositNetworks().getOrThrow() } + val quote = quote(now.epochSeconds.toULong() + 120u) + var authorized = false + val send = launch { + val result = repo.send(quote) { authorized = true } + assertTrue(result.exceptionOrNull() is UsdtException.QuoteExpired) + } + runCurrent() + assertEquals(false, authorized) + now += 116.seconds + release.complete(Unit) + preceding.join() + send.join() + assertTrue(authorized) + verify(service, never()).send(quote) + } + + @Test + fun `fee quote is rechecked after authorization finishes`() = test { + var now = Instant.fromEpochSeconds(1_800_000_000) + val clock = object : Clock { override fun now() = now } + val quote = quote(now.epochSeconds.toULong() + 120u) + val repo = UsdtRepo(service, testDispatcher, clock) + + val result = repo.send(quote) { now += 120.seconds } + + assertTrue(result.exceptionOrNull() is UsdtException.QuoteExpired) + verify(service, never()).send(quote) + } + + @Test + fun `only new confirmed receipts celebrate after initial sync`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.balance()).thenReturn(1_000_000uL) + whenever(wallet.syncHistory()).thenReturn(true) + val old = incoming("old") + var history = listOf(old) + whenever(wallet.history()).thenAnswer { history } + whenever(wallet.refreshTransfers()).thenAnswer { history } + val repo = UsdtRepo(service, testDispatcher) + val events = mutableListOf() + val collector = launch { repo.receivedTxs.collect { events.add(it) } } + runCurrent() + try { + repo.refresh(true, force = true).getOrThrow() + runCurrent() + assertTrue(events.isEmpty()) + val received = incoming("new") + val pending = incoming("pending").copy(status = UsdtTransferStatus.PENDING, txHash = null) + history = listOf( + old, received, pending, incoming("sent").copy(isIncoming = false), + incoming("failed").copy(status = UsdtTransferStatus.FAILED), incoming("zero").copy(amount = 0uL) + ) + repo.refresh(true, force = true).getOrThrow() + runCurrent() + assertEquals(listOf(received), events) + repo.refresh(true, force = true).getOrThrow() + runCurrent() + assertEquals(listOf(received), events) + val confirmed = pending.copy(status = UsdtTransferStatus.CONFIRMED, txHash = "pending") + history = history.map { if (it.id == confirmed.id) confirmed else it } + repo.refresh(true, force = true).getOrThrow() + runCurrent() + assertEquals(listOf(received, confirmed), events) + whenever(wallet.receiveAddress()).thenReturn("another wallet") + history = listOf(incoming("restored")) + repo.refresh(true, force = true).getOrThrow() + runCurrent() + assertEquals(listOf(received, confirmed), events) + } finally { + collector.cancel() + } + } + + @Test + fun `partial history remains visible without declaring initial sync complete`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(listOf(incoming("old"))) + whenever(wallet.balance()).thenReturn(1_000_000uL) + whenever(wallet.refreshTransfers()).thenReturn(emptyList()) + whenever(wallet.syncHistory()).thenReturn(false, true) + val repo = UsdtRepo(service, testDispatcher) + repo.refresh(true, force = true).getOrThrow() + assertEquals(1_000_000uL, repo.state.value.balance) + assertEquals(listOf(incoming("old")), repo.state.value.transfers) + assertEquals(false, repo.state.value.historyComplete) + repo.refresh(true, force = true).getOrThrow() + assertTrue(repo.state.value.historyComplete) + } + + @Test + fun `execution checks bypass normal cooldown and preserve rate limit backoff`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("address") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.balance()).thenReturn(1_000_000uL) + whenever(wallet.refreshTransfers()).thenReturn(emptyList()) + val sent = incoming("sent").copy(isIncoming = false) + whenever(wallet.checkRecentExecution("sent")).thenReturn(sent) + whenever(wallet.checkRecentExecution("limited")).thenThrow(UsdtException.RateLimited()) + val repo = UsdtRepo(service, testDispatcher) + repo.refresh(false).getOrThrow() + repo.waitForTransfer("sent").getOrThrow() + assertEquals(listOf(sent), repo.state.value.transfers) + assertTrue(repo.waitForTransfer("limited").exceptionOrNull() is UsdtException.RateLimited) + repo.waitForTransfer("backoff").getOrThrow() + verify(wallet, never()).checkRecentExecution("backoff") + assertTrue(repo.refresh(true, force = true).exceptionOrNull() is UsdtException.RateLimited) + verify(wallet, never()).syncHistory() + } + + private fun incoming(id: String) = UsdtTransfer( + sender = null, + id = id, txHash = id, userOperationHash = null, bridgeGuid = null, orchestra = null, recipient = "recipient", + destination = UsdtDestination.ARBITRUM, amount = 1_000_000uL, receivedAmount = 1_000_000uL, + fee = null, isIncoming = true, status = UsdtTransferStatus.CONFIRMED, + timestamp = 1uL, + ) +} diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 13371711ef..565c6b2604 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -661,6 +661,8 @@ class PaykitSdkServiceTest { assertFalse(backup.isCompleted) verify(sdk, never()).exportBackupString() service.linkedPeers(Priority.Interactive) + assertEquals("pending-write-backup", service.exportBackupState(Priority.Interactive)) + assertFalse(backup.isCompleted) if (cancelBackup) { backup.cancel() assertFailsWith { backup.await() } @@ -668,7 +670,7 @@ class PaykitSdkServiceTest { service.setPaymentSubmissionActive(false) runCurrent() if (!cancelBackup) assertEquals("pending-write-backup", backup.await()) - verify(sdk, times(if (cancelBackup) 0 else 1)).exportBackupString() + verify(sdk, times(if (cancelBackup) 1 else 2)).exportBackupString() } } diff --git a/app/src/test/java/to/bitkit/ui/components/PaykitAmountViewTest.kt b/app/src/test/java/to/bitkit/ui/components/PaykitAmountViewTest.kt new file mode 100644 index 0000000000..af1b23370c --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/components/PaykitAmountViewTest.kt @@ -0,0 +1,22 @@ +package to.bitkit.ui.components + +import org.junit.Test +import java.util.Locale +import kotlin.test.assertEquals + +class PaykitAmountViewTest { + @Test + fun `usd display preserves cents and uses locale grouping`() { + listOf( + Triple(0uL, "0.00", "0,00"), + Triple(1uL, "0.01", "0,01"), + Triple(100uL, "1.00", "1,00"), + Triple(15050uL, "150.50", "150,50"), + Triple(123456uL, "1,234.56", "1.234,56"), + Triple(ULong.MAX_VALUE, "184,467,440,737,095,516.15", "184.467.440.737.095.516,15"), + ).forEach { (cents, us, de) -> + assertEquals(us, usdDisplayAmount(cents, Locale.US), "cents: $cents") + assertEquals(de, usdDisplayAmount(cents, Locale.GERMANY), "cents: $cents") + } + } +} diff --git a/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt b/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt new file mode 100644 index 0000000000..8a7df93943 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt @@ -0,0 +1,46 @@ +package to.bitkit.ui.components + +import org.junit.Test +import to.bitkit.models.PaykitExchangeRate +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UsdtDisplayTest { + @Test + fun `exact amounts preserve token precision and locale separators`() { + assertEquals("4.996", usdtExactAmount(4_996_000u, Locale.US)) + assertEquals("1,234567", usdtExactAmount(1_234_567u, Locale.GERMANY)) + assertEquals("3,50", usdtExactAmount(3_500_000u, Locale.GERMANY)) + } + + @Test + fun `bitcoin equivalent requires a fresh rate and fits display bounds`() { + val now = 1_800_000_000_000L + val rate = PaykitExchangeRate("100000", now) + assertEquals(3500L, usdtDisplaySats("3.5", rate, now)) + assertEquals(0L, usdtDisplaySats("0.000001", rate, now)) + assertNull(usdtDisplaySats("3.5", null, now)) + assertNull(usdtDisplaySats("3.5", rate.copy(timestampMillis = now - 601_000), now)) + assertNull(usdtDisplaySats("-1", rate, now)) + assertNull(usdtDisplaySats("999999999999999999999999", rate, now)) + } + + @Test + fun `overview amounts round to cents without hiding small balances`() { + listOf( + 0uL to "0.00", + 1uL to "<0.01", + 9999uL to "<0.01", + 10000uL to "0.01", + 10000000uL to "10.00", + 123456789uL to "123.46", + 1025000uL to "1.03", + 1234567890uL to "1,234.57", + ).forEach { (amount, expected) -> + assertEquals(expected, usdtOverviewAmount(amount, Locale.US), "amount: $amount") + } + assertEquals("<0,01", usdtOverviewAmount(1uL, Locale.GERMANY)) + assertEquals("1.234,57", usdtOverviewAmount(1234567890uL, Locale.GERMANY)) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt index 29d06c1924..865966a391 100644 --- a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -3,6 +3,8 @@ package to.bitkit.ui.screens.paymentrequests import com.synonym.paykit.PaymentRequestLifecycleState import org.junit.Test import to.bitkit.R +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDirection @@ -71,8 +73,8 @@ class PaymentRequestPresentationTest { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - amountValue = "0.000025", - amountSats = 2_500uL, + amount = PaykitAmount(PaykitAsset.BTC, 2_500uL), + paymentReference = "fixture-reference", expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf("lightning-bolt11"), direction = direction, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt index c481ecac23..c7d64cc209 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt @@ -4,6 +4,7 @@ import android.content.Context import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.doReturn @@ -33,6 +34,7 @@ class PubkyAuthApprovalRetryTest : BaseUnitTest() { @Test fun `relay timeout restores consent and requires local auth for retry`() = test { whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever(context.getString(R.string.profile__auth_approval_services_separator)).thenReturn(" and ") whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") val authUrl = "pubkyring://signup?hs=homeserver" + "&relay=https://relay.example/inbox/&secret=secret&caps=/pub/example/:rw" @@ -43,7 +45,14 @@ class PubkyAuthApprovalRetryTest : BaseUnitTest() { Result.failure(AppError(PubkyRingAuthTimeoutError())), Result.success(Unit), ) - val sut = PubkyAuthApprovalViewModel(context, pubkyRepo, mock()) + val sut = + PubkyAuthApprovalViewModel( + context, + pubkyRepo, + mock(), + mock(), + mock { on { enabled } doReturn flowOf(false) } + ) sut.effects.test { sut.load(authUrl) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index c4dc97867b..20dfc4741c 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -3,12 +3,17 @@ package to.bitkit.ui.screens.profile import android.content.Context import android.util.Log import app.cash.turbine.test +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.usdtParsePaymentRequest import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Before import org.junit.Test import org.mockito.Mockito.mockStatic @@ -26,6 +31,7 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.R +import to.bitkit.models.PaykitUsdt import to.bitkit.models.PreparedWatchOnlyAccountClaim import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthClaim.Item @@ -34,13 +40,18 @@ import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.Endpoint +import to.bitkit.repositories.MethodId import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.UsdtRepo import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @Suppress("LargeClass") @@ -57,11 +68,14 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { on { displayName } doReturn displayNameFlow on { displayImageUri } doReturn displayImageUriFlow } + private val usdtEnabled = MutableStateFlow(true) + private val usdtRepo: UsdtRepo = mock() private val watchOnlyAccountRepo: WatchOnlyAccountRepo = mock() @Before fun setUp() { whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever(context.getString(R.string.profile__auth_approval_services_separator)).thenReturn(" and ") whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") whenever( context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") @@ -525,7 +539,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Success, sut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo) { prepareUnsignedClaim(authUrl, "paykit server") } - verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + verifyBlocking(pubkyRepo) { + approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities, clientId) } verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } @@ -561,7 +577,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Success, sut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + verifyBlocking(pubkyRepo, times(1)) { + approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } } @@ -584,7 +602,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + whenever { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } .doSuspendableAnswer { approvalResult.await() } whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) val sut = createSut() @@ -617,7 +637,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(pubkyRepo, times(1)) { parseAuthUrl(secondAuthUrl) } verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + verifyBlocking(pubkyRepo, times(1)) { + approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } verifyBlocking(pubkyRepo, never()) { approveAuth(secondAuthUrl, secondCapabilities, clientId) } verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } } @@ -638,7 +660,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + whenever { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } .thenReturn(Result.failure(AppError(authorizationError))) val sut = createSut() @@ -667,7 +691,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + whenever { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) val sut = createSut() @@ -700,7 +726,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(true) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + whenever { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) val sut = createSut() @@ -747,7 +775,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Authorize, sut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + verifyBlocking(pubkyRepo, never()) { + approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } } @@ -786,7 +816,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id, preserveAuthorizingState = true) } - verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + verifyBlocking(pubkyRepo, never()) { + approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } } @Test @@ -842,14 +874,309 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Success, restartedSut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo, times(2)) { prepareUnsignedClaim(authUrl, "paykit server") } verifyBlocking(watchOnlyAccountRepo, times(2)) { beginAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, times(2)) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + verifyBlocking(pubkyRepo, times(2)) { + approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + } verifyBlocking(watchOnlyAccountRepo, times(2)) { markActive(prepared.account.id) } } + @Test + fun `receiving details share exactly the requested assets`() = test { + val address = "0x1111111111111111111111111111111111111111" + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { core -> + core.`when` { usdtParsePaymentRequest(address) } + .thenReturn(UsdtPaymentRequest(address, null, null)) + val endpoint = PaykitUsdt.endpoint(address) + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.success(endpoint)) + for (claim in listOf(PubkyAuthClaim.USDT_ADDRESS_V1, PubkyAuthClaim.BITCOIN_AND_USDT)) { + clearInvocations(watchOnlyAccountRepo) + val url = "pubkyauth://signin_grant?x-bitkit-claim=${claim.wireValue}" + whenever( + pubkyRepo.parseAuthUrl(url) + ).thenReturn(Result.success(authRequest(url, PubkyAuthClaim.REQUIRED_CAPABILITIES, claim))) + val prepared = PreparedWatchOnlyAccountClaim(watchOnlyAccount(), byteArrayOf(1)) + whenever(watchOnlyAccountRepo.prepareUnsignedClaim(url, "paykit server")).thenReturn(prepared) + whenever(watchOnlyAccountRepo.beginAuthorization(prepared.account.id)).thenReturn(false) + var shared: ByteArray? = null + whenever(pubkyRepo.approveAuthWithCompanionClaim(eq(url), eq(clientId), any())) + .thenAnswer { + shared = it.getArgument(2) + Result.success(Unit) + } + val sut = createSut() + sut.load(url) + advanceUntilIdle() + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + sut.approveWatchOnlyConsent(url) + assertTrue(sut.uiState.value.canAuthorize) + sut.confirmAuthorize(url) + advanceUntilIdle() + assertEquals(ApprovalState.Success, sut.uiState.value.state) + val details = Json.parseToJsonElement(checkNotNull(shared).decodeToString()).jsonObject + val usdt = details.getValue("usdt-arbitrum-address").jsonObject + assertEquals(address, usdt.getValue("value").jsonPrimitive.content) + assertEquals(PaykitUsdt.CHAIN_ID, usdt.getValue("chain_id").jsonPrimitive.content) + assertEquals(PaykitUsdt.TOKEN, usdt.getValue("token").jsonPrimitive.content) + assertEquals( + if (claim.sharesBitcoin) { + setOf( + "bitcoin_account", + "usdt-arbitrum-address" + ) + } else { + setOf("usdt-arbitrum-address") + }, + details.keys + ) + if (claim.sharesBitcoin) { + assertEquals( + prepared.account.xpub, + details.getValue("bitcoin_account").jsonObject.getValue("xpub").jsonPrimitive.content + ) + verifyBlocking(watchOnlyAccountRepo) { markActive(prepared.account.id) } + } else { + verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } + } + } + } + } + + @Test + fun `Earn sharing choice survives approval and back navigation`() = test { + val url = stubCombinedAuthorization() + whenever(usdtRepo.paymentEndpoint()).thenReturn( + Result.success(Endpoint(MethodId.UsdtArbitrum, "reviewed-address", null, null, "{}")) + ) + val sut = createSut() + sut.load(url) + advanceUntilIdle() + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + assertTrue(sut.uiState.value.shareUsdt) + sut.setShareUsdt(false) + sut.approveWatchOnlyConsent(url) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + assertFalse(sut.uiState.value.shareUsdt) + assertTrue(sut.uiState.value.canAuthorize) + sut.returnToWatchOnlyConsent(url) + assertFalse(sut.uiState.value.shareUsdt) + sut.setShareUsdt(true) + assertTrue(sut.uiState.value.shareUsdt) + sut.approveWatchOnlyConsent(url) + assertTrue(sut.uiState.value.shareUsdt) + } + + @Test + fun `unavailable optional address can be skipped or retried`() = test { + val url = "pubkyauth://signin_grant?x-bitkit-claim=usdt-address-v1" + whenever( + pubkyRepo.parseAuthUrl(url) + ).thenReturn( + Result.success( + authRequest(url, PubkyAuthClaim.REQUIRED_CAPABILITIES, PubkyAuthClaim.BITCOIN_AND_USDT) + ) + ) + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(AppError("Unavailable"))) + val sut = createSut() + sut.load(url) + advanceUntilIdle() + assertTrue(sut.uiState.value.canAuthorize) + assertFalse(sut.uiState.value.shareUsdt) + assertTrue(sut.uiState.value.usdtUnavailable) + sut.setShareUsdt(true) + assertFalse(sut.uiState.value.canAuthorize) + sut.approveWatchOnlyConsent(url) + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + val endpoint = Endpoint( + methodId = MethodId.UsdtArbitrum, + value = "reviewed-address", + rawPayload = "{}" + ) + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.success(endpoint)) + sut.loadUsdtAddress(url) + advanceUntilIdle() + assertTrue(sut.uiState.value.canAuthorize) + assertFalse(sut.uiState.value.usdtUnavailable) + } + + @Test + fun `disabled combined authorization shares Bitcoin only`() = test { + usdtEnabled.value = false + val url = stubCombinedAuthorization() + var shared: ByteArray? = null + whenever(pubkyRepo.approveAuthWithCompanionClaim(eq(url), eq(clientId), any())).thenAnswer { + shared = it.getArgument(2) + Result.success(Unit) + } + val sut = createSut() + sut.load(url) + advanceUntilIdle() + assertTrue(sut.uiState.value.showsReceivingDetails) + assertFalse(sut.uiState.value.showsUsdtDetails) + sut.approveWatchOnlyConsent(url) + sut.setShareUsdt(true) + assertFalse(sut.uiState.value.shareUsdt) + assertTrue(sut.uiState.value.canAuthorize) + sut.confirmAuthorize(url) + advanceUntilIdle() + assertEquals(ApprovalState.Success, sut.uiState.value.state) + assertEquals( + setOf("bitcoin_account"), + Json.parseToJsonElement(checkNotNull(shared).decodeToString()).jsonObject.keys + ) + verifyNoInteractions(usdtRepo) + } + + @Test + fun `USDT-only authorization is unavailable when disabled or its address cannot be loaded`() = test { + val url = "pubkyauth://signin_grant?x-bitkit-claim=usdt-address-v1" + whenever(pubkyRepo.parseAuthUrl(url)).thenReturn( + Result.success(authRequest(url, PubkyAuthClaim.REQUIRED_CAPABILITIES, PubkyAuthClaim.USDT_ADDRESS_V1)) + ) + usdtEnabled.value = false + val sut = createSut() + sut.load(url) + advanceUntilIdle() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + assertFalse(sut.uiState.value.showsReceivingDetails) + assertFalse(sut.uiState.value.canAuthorize) + sut.confirmAuthorize(url) + advanceUntilIdle() + verifyNoInteractions(usdtRepo) + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + + usdtEnabled.value = true + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.failure(AppError("Unavailable"))) + sut.load(url) + advanceUntilIdle() + sut.approveWatchOnlyConsent(url) + assertTrue(sut.uiState.value.usdtUnavailable) + assertFalse(sut.uiState.value.canAuthorize) + sut.confirmAuthorize(url) + advanceUntilIdle() + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + + @Test + fun `combined authorization rechecks USDT preference after Bitcoin account preparation`() = test { + val url = stubCombinedAuthorization() + val endpoint = Endpoint(MethodId.UsdtArbitrum, "reviewed-address", null, null, "{}") + whenever(usdtRepo.paymentEndpoint()).thenReturn(Result.success(endpoint)) + val prepared = PreparedWatchOnlyAccountClaim(watchOnlyAccount(), byteArrayOf(1)) + whenever(watchOnlyAccountRepo.prepareUnsignedClaim(url, "paykit server")).thenAnswer { + usdtEnabled.value = false + prepared + } + var shared: ByteArray? = null + whenever(pubkyRepo.approveAuthWithCompanionClaim(eq(url), eq(clientId), any())).thenAnswer { + shared = it.getArgument(2) + Result.success(Unit) + } + val sut = createSut() + sut.load(url) + advanceUntilIdle() + sut.approveWatchOnlyConsent(url) + assertTrue(sut.uiState.value.canAuthorize) + sut.confirmAuthorize(url) + advanceUntilIdle() + assertEquals(ApprovalState.Success, sut.uiState.value.state) + assertFalse(sut.uiState.value.shareUsdt) + assertEquals( + setOf("bitcoin_account"), + Json.parseToJsonElement(checkNotNull(shared).decodeToString()).jsonObject.keys + ) + } + + @Test + fun `disabled optional USDT does not block a Paykit access grant`() = test { + usdtEnabled.value = false + val claim = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.USDT_ADDRESS_V1) + val url = "pubkyauth://signin_grant?x-bitkit-claim=${claim.wireValue}" + whenever(pubkyRepo.parseAuthUrl(url)).thenReturn( + Result.success(authRequest(url, PubkyAuthClaim.REQUIRED_CAPABILITIES, claim)) + ) + whenever(pubkyRepo.approveAuthWithCompanionClaim(eq(url), eq(clientId), any())).thenReturn(Result.success(Unit)) + val sut = createSut() + sut.load(url) + advanceUntilIdle() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + assertTrue(sut.uiState.value.canAuthorize) + assertFalse(sut.uiState.value.showsReceivingDetails) + sut.confirmAuthorize(url) + advanceUntilIdle() + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { + approveAuthWithCompanionClaim(eq(url), eq(clientId), argThat { decodeToString() == "{}" }) + } + verifyNoInteractions(usdtRepo, watchOnlyAccountRepo) + } + + @Test + fun `enabling USDT in an open combined authorization loads its address without opting in`() = test { + usdtEnabled.value = false + val url = stubCombinedAuthorization() + whenever(usdtRepo.paymentEndpoint()).thenReturn( + Result.success(Endpoint(MethodId.UsdtArbitrum, "reviewed-address", null, null, "{}")) + ) + val sut = createSut() + sut.load(url) + advanceUntilIdle() + usdtEnabled.value = true + advanceUntilIdle() + assertTrue(sut.uiState.value.showsUsdtDetails) + assertEquals("reviewed-address", sut.uiState.value.usdtAddress) + assertFalse(sut.uiState.value.shareUsdt) + assertTrue(sut.uiState.value.canAuthorize) + sut.setShareUsdt(true) + assertTrue(sut.uiState.value.shareUsdt) + sut.setShareUsdt(false) + usdtEnabled.value = false + advanceUntilIdle() + usdtEnabled.value = true + advanceUntilIdle() + assertEquals("reviewed-address", sut.uiState.value.usdtAddress) + assertFalse(sut.uiState.value.shareUsdt) + } + + @Test + fun `enabling USDT in an open required authorization loads its receiving details`() = test { + usdtEnabled.value = false + val url = "pubkyauth://signin_grant?x-bitkit-claim=usdt-address-v1" + whenever(pubkyRepo.parseAuthUrl(url)).thenReturn( + Result.success(authRequest(url, PubkyAuthClaim.REQUIRED_CAPABILITIES, PubkyAuthClaim.USDT_ADDRESS_V1)) + ) + whenever(usdtRepo.paymentEndpoint()).thenReturn( + Result.success(Endpoint(MethodId.UsdtArbitrum, "reviewed-address", null, null, "{}")) + ) + val sut = createSut() + sut.load(url) + advanceUntilIdle() + assertFalse(sut.uiState.value.canAuthorize) + usdtEnabled.value = true + advanceUntilIdle() + assertEquals("reviewed-address", sut.uiState.value.usdtAddress) + assertFalse(sut.uiState.value.usdtUnavailable) + assertTrue(sut.uiState.value.canAuthorize) + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + } + + private suspend fun stubCombinedAuthorization(): String { + val claim = PubkyAuthClaim.BITCOIN_AND_USDT + val url = "pubkyauth://signin_grant?x-bitkit-claim=${claim.wireValue}" + whenever(pubkyRepo.parseAuthUrl(url)).thenReturn( + Result.success(authRequest(url, PubkyAuthClaim.REQUIRED_CAPABILITIES, claim)) + ) + val prepared = PreparedWatchOnlyAccountClaim(watchOnlyAccount(), byteArrayOf(1)) + whenever(watchOnlyAccountRepo.prepareUnsignedClaim(url, "paykit server")).thenReturn(prepared) + whenever(watchOnlyAccountRepo.beginAuthorization(prepared.account.id)).thenReturn(false) + return url + } + private fun createSut() = PubkyAuthApprovalViewModel( context = context, pubkyRepo = pubkyRepo, watchOnlyAccountRepo = watchOnlyAccountRepo, + usdtRepo = usdtRepo, + usdtPreferences = mock { on { enabled }.thenReturn(usdtEnabled) }, ) private fun authRequest( diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index 41a8f61c45..858849a91b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -6,6 +6,10 @@ import com.synonym.paykit.PaymentRequestLifecycleState import org.junit.Test import to.bitkit.R import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitExchangeRate +import to.bitkit.models.PaykitRequestPricing import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription @@ -16,6 +20,7 @@ import to.bitkit.repositories.PaykitSubscriptionRole import to.bitkit.repositories.runsUntilPaidThrough import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -24,6 +29,19 @@ class SubscriptionsScreenTest { private val now = Instant.parse("2027-01-15T08:00:00Z") private val paidThrough = Instant.parse("2027-02-01T08:00:00Z") + @Test + fun `monthly cost combines assets using the wallet rate`() { + val amounts = listOf( + PaykitAmount(PaykitAsset.BTC, 100_000UL), + PaykitAmount(PaykitAsset.USD, 200UL), + PaykitAmount(PaykitAsset.USDT, 3_000_000UL), + ) + val rate = PaykitExchangeRate("100000", now.toEpochMilliseconds()) + assertEquals(105_000L, subscriptionMonthlyCostSats(amounts, rate, now.toEpochMilliseconds())) + assertNull(subscriptionMonthlyCostSats(amounts, null, now.toEpochMilliseconds())) + assertEquals(100_000L, subscriptionMonthlyCostSats(amounts.take(1), null, now.toEpochMilliseconds())) + } + @Test fun `next transition includes the next recurring period`() { assertEquals( @@ -53,14 +71,14 @@ class SubscriptionsScreenTest { cases.forEach { (unit, every, expectedSats) -> assertEquals( expectedSats, - subscriptionMonthlyCostSats(listOf(subscription(unit, every, 1_200u)), now), + subscriptionMonthlyCosts(listOf(subscription(unit, every, 1_200u)), now).single().atomic.toLong(), "Unexpected monthly cost for every '$every' '$unit'", ) } val lowCostYearlySubscriptions = List(3) { index -> subscription(PaykitRecurrenceUnit.Year, amountSats = 10u).copy(paymentRequestId = "low-cost-$index") } - assertEquals(3L, subscriptionMonthlyCostSats(lowCostYearlySubscriptions, now)) + assertEquals(3L, subscriptionMonthlyCosts(lowCostYearlySubscriptions, now).single().atomic.toLong()) } @Test @@ -111,7 +129,7 @@ class SubscriptionsScreenTest { assertEquals( 1_200L, - subscriptionMonthlyCostSats(listOf(paidActive, canceled, proposed), now), + subscriptionMonthlyCosts(listOf(paidActive, canceled, proposed), now).single().atomic.toLong(), ) } @@ -230,7 +248,7 @@ class SubscriptionsScreenTest { assertEquals(emptyList(), sections.active) assertEquals(emptyList(), sections.expired) assertEquals(paidThrough, nextSubscriptionTransition(listOf(created), now)) - assertEquals(0L, subscriptionMonthlyCostSats(listOf(created), now)) + assertEquals(0L, subscriptionMonthlyCosts(listOf(created), now).single().atomic.toLong()) } @Test @@ -319,10 +337,10 @@ class SubscriptionsScreenTest { @Test fun `monthly cost counts a canceled subscription until its paid period ends`() { - val canceled = canceledWithPaidThrough().copy(amountSats = 1_200u) + val canceled = canceledWithPaidThrough().copy(amount = PaykitAmount(PaykitAsset.BTC, 1_200u)) - assertEquals(1_200L, subscriptionMonthlyCostSats(listOf(canceled), now)) - assertEquals(0L, subscriptionMonthlyCostSats(listOf(canceled), paidThrough)) + assertEquals(1_200L, subscriptionMonthlyCosts(listOf(canceled), now).single().atomic.toLong()) + assertEquals(0L, subscriptionMonthlyCosts(listOf(canceled), paidThrough).single().atomic.toLong()) } @Test @@ -359,8 +377,9 @@ class SubscriptionsScreenTest { ) = PaykitSubscription( paymentRequestId = "subscription", counterparty = "pubkypayee", - amountValue = "0.001", - amountSats = amountSats, + amount = PaykitAmount(PaykitAsset.BTC, amountSats), + paymentReference = "fixture-reference", + pricing = PaykitRequestPricing(), note = "Subscription", createdAt = Instant.parse("2027-01-01T08:00:00Z"), proposalExpiresAt = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index 7c5da8ba80..c96a2c9dc8 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.trezor +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AddressInfo import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WatcherEvent @@ -170,6 +171,32 @@ class TrezorViewModelTest : BaseUnitTest() { assertEquals(input, sut.uiState.value.lookupInput) } + @Test + fun `populateWatcherFromXpub should copy the xpub and select the account type of its path`() { + // Core 0.7.0 returns a normalized xpub/tpub, which prefix detection would read as legacy. + val publicKey = TrezorPreviewData.samplePublicKeyResponse + trezorStateFlow.value = TrezorState(lastPublicKey = publicKey) + + sut.populateWatcherFromXpub() + + val watcher = sut.uiState.value.watcher + assertEquals(publicKey.xpub, watcher.extendedKey) + assertEquals(AccountType.NATIVE_SEGWIT, watcher.selectedAccountType) + } + + @Test + fun `populateWatcherFromXpub should keep the selected account type for an unknown path`() { + val publicKey = TrezorPreviewData.samplePublicKeyResponse.copy(path = "m/45'/0'/0'") + trezorStateFlow.value = TrezorState(lastPublicKey = publicKey) + sut.setWatcherAccountType(AccountType.TAPROOT) + + sut.populateWatcherFromXpub() + + val watcher = sut.uiState.value.watcher + assertEquals(publicKey.xpub, watcher.extendedKey) + assertEquals(AccountType.TAPROOT, watcher.selectedAccountType) + } + // endregion // region Async methods diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt index ea83f7dc1f..1c0cb0bb4a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt @@ -17,6 +17,7 @@ import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsData +import to.bitkit.env.Env import to.bitkit.models.BalanceState import to.bitkit.models.HwWallet import to.bitkit.models.Suggestion @@ -28,9 +29,12 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.SuggestionsRepo import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.UsdtRepo +import to.bitkit.repositories.UsdtWalletState import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -47,6 +51,8 @@ class HomeViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val hwWalletRepo = mock() private val suggestionsRepo = mock() + private val usdtRepo = mock() + private val usdtState = MutableStateFlow(UsdtWalletState()) private lateinit var hardwareWallets: MutableStateFlow> private lateinit var suggestions: MutableStateFlow> @@ -74,6 +80,7 @@ class HomeViewModelTest : BaseUnitTest() { }.thenReturn(Result.success(emptyList())) whenever(hwWalletRepo.wallets).thenReturn(hardwareWallets) whenever(suggestionsRepo.suggestionsFlow).thenReturn(suggestions) + whenever(usdtRepo.state).thenReturn(usdtState) } @Test @@ -107,6 +114,17 @@ class HomeViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.showEmptyState) } + @Test + fun `empty state follows the enabled USDT balance`() = test { + val sut = createViewModel() + advanceUntilIdle() + assertTrue(sut.uiState.value.showEmptyState) + + usdtState.value = UsdtWalletState(balance = 50_000uL) + advanceUntilIdle() + assertEquals(!Env.isUsdtEnabled, sut.uiState.value.showEmptyState) + } + @Test fun `onPullToRefresh refreshes rates and widgets`() = test { val sut = createViewModel() @@ -146,6 +164,7 @@ class HomeViewModelTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletRepo = hwWalletRepo, suggestionsRepo = suggestionsRepo, + usdtRepo = usdtRepo, ) private fun hardwareWallet(balanceSats: ULong = 0uL) = HwWallet( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/activity/UsdtActivityFilterTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/activity/UsdtActivityFilterTest.kt new file mode 100644 index 0000000000..f751d00b7b --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/activity/UsdtActivityFilterTest.kt @@ -0,0 +1,40 @@ +package to.bitkit.ui.screens.wallets.activity + +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import org.junit.Test +import to.bitkit.ui.screens.wallets.activity.components.ActivityTab +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UsdtActivityFilterTest { + @Test + fun `search matches full asset name or recipient and transaction fragments`() { + val transfer = UsdtTransfer( + sender = null, + id = "received", + txHash = "0xAbCd5678", + userOperationHash = null, + bridgeGuid = null, + orchestra = null, + recipient = "0xEf901234", + destination = UsdtDestination.ARBITRUM, + amount = 1_000_000u, + receivedAmount = 1_000_000u, + fee = null, + isIncoming = true, + status = UsdtTransferStatus.CONFIRMED, + timestamp = 1u, + ) + for (query in listOf("", "USDT", "usdt", "eF90", "aBcD")) { + assertTrue(transfer.matchesActivityFilter(ActivityTab.ALL, true, query, null, null), query) + } + for (query in listOf("u", "us", "t", "BTC", "no match")) { + assertFalse(transfer.matchesActivityFilter(ActivityTab.ALL, true, query, null, null), query) + } + val unconfirmed = transfer.copy(txHash = null) + assertTrue(unconfirmed.matchesActivityFilter(ActivityTab.ALL, true, "eF90", null, null)) + assertFalse(unconfirmed.matchesActivityFilter(ActivityTab.ALL, true, "aBcD", null, null)) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositViewModelTest.kt new file mode 100644 index 0000000000..ec30cd837f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositViewModelTest.kt @@ -0,0 +1,324 @@ +package to.bitkit.ui.screens.wallets.usdt + +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDepositAddress +import com.synonym.bitkitcore.UsdtDepositDetail +import com.synonym.bitkitcore.UsdtDepositNetwork +import com.synonym.bitkitcore.UsdtDepositOrder +import com.synonym.bitkitcore.UsdtDepositPage +import com.synonym.bitkitcore.UsdtException +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import org.junit.Test +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.repositories.UsdtDepositRepo +import to.bitkit.test.BaseUnitTest +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UsdtDepositViewModelTest : BaseUnitTest() { + private val repo: UsdtDepositRepo = mock() + + @Test + fun `USD cents respect locale grouping and decimal separators`() { + assertEquals("$10,000.00", "1000000".usdCents(Locale.US)) + assertEquals("$10.000,00", "1000000".usdCents(Locale.GERMANY)) + assertEquals("$0,05", "5".usdCents(Locale.GERMANY)) + } + + @Test + fun `background detail refresh leaves editing enabled and updates refund eligibility`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf())) + val deposit = UsdtDeposit("deposit", "bsc", "USDT", 100u, "tx", "held", null, null) + whenever(repo.depositDetail("deposit", 0u)).thenReturn(Result.success(UsdtDepositDetail(deposit, null))) + val model = UsdtDepositViewModel(repo) + model.detail("deposit") + val result = CompletableDeferred>() + whenever(repo.depositDetail("deposit", 0u)).doSuspendableAnswer { result.await() } + + val refresh = launch { model.refreshDetail("deposit") } + assertFalse(model.state.value.busy) + assertTrue(requireNotNull(model.state.value.detail).canRefund) + result.complete(Result.success(UsdtDepositDetail(deposit.copy(status = "completed"), null))) + refresh.join() + assertFalse(model.state.value.busy) + assertFalse(requireNotNull(model.state.value.detail).canRefund) + } + + @Test + fun `background detail refresh cannot reopen a deposit after back`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf())) + val deposit = UsdtDeposit("deposit", "bsc", "USDT", 100u, "tx", "held", null, null) + val detail = UsdtDepositDetail(deposit, null) + whenever(repo.depositDetail("deposit", 0u)).thenReturn(Result.success(detail)) + val model = UsdtDepositViewModel(repo) + model.detail("deposit") + val result = CompletableDeferred>() + whenever(repo.depositDetail("deposit", 0u)).doSuspendableAnswer { result.await() } + + val refresh = launch { model.refreshDetail("deposit") } + assertTrue(model.back()) + result.complete(Result.success(detail)) + refresh.join() + assertNull(model.state.value.detail) + assertFalse(model.state.value.busy) + } + + @Test + fun `background detail refresh cannot undo an acknowledged refund or duplicate its request`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf())) + val deposit = UsdtDeposit("deposit", "bsc", "USDT", 100u, "tx", "held", null, null) + val detail = UsdtDepositDetail(deposit, null) + whenever(repo.depositDetail("deposit", 0u)).thenReturn(Result.success(detail)) + whenever(repo.refundDeposit("deposit", 0u, "address", UsdtDepositNetwork.BSC)).thenReturn(Result.success(Unit)) + val model = UsdtDepositViewModel(repo) + model.detail("deposit") + val result = CompletableDeferred>() + whenever(repo.depositDetail("deposit", 0u)).doSuspendableAnswer { result.await() } + + val refresh = launch { model.refreshDetail("deposit") } + model.refund("deposit", 0u, "address", UsdtDepositNetwork.BSC).join() + result.complete(Result.success(detail)) + refresh.join() + assertEquals("refund_requested", model.state.value.detail?.deposit?.status) + assertEquals(R.string.usdt__deposit_refund_requested, model.state.value.message) + model.refund("deposit", 0u, "address", UsdtDepositNetwork.BSC).join() + verify(repo, times(1)).refundDeposit("deposit", 0u, "address", UsdtDepositNetwork.BSC) + } + + @Test + fun `bridge cost rejection shows no deposit address or invented minimum`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf(UsdtDepositNetwork.TRON))) + whenever(repo.prepareDeposit(UsdtDepositNetwork.TRON, "3.5")) + .thenReturn(Result.failure(UsdtException.BridgeAmountTooSmall())) + val model = UsdtDepositViewModel(repo) + model.selectNetwork(UsdtDepositNetwork.TRON) + model.prepare("3.5").join() + assertEquals(R.string.usdt__error_bridge_amount, model.state.value.error) + assertNull(model.state.value.amountLimits) + assertNull(model.state.value.address) + assertFalse(model.state.value.busy) + model.clearAmountError() + assertNull(model.state.value.error) + } + + @Test + fun `editing a rejected amount clears its error and limits`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf(UsdtDepositNetwork.POLYGON))) + val error = UsdtException.DepositAmountOutOfRange("200", "1200000000") + whenever(repo.prepareDeposit(UsdtDepositNetwork.POLYGON, "1")).thenReturn(Result.failure(error)) + val model = UsdtDepositViewModel(repo) + model.selectNetwork(UsdtDepositNetwork.POLYGON) + model.prepare("1").join() + assertEquals(R.string.usdt__deposit_amount_out_of_range, model.state.value.error) + assertEquals(error, model.state.value.amountLimits) + model.clearAmountError() + assertNull(model.state.value.error) + assertNull(model.state.value.amountLimits) + assertEquals(UsdtDepositNetwork.POLYGON, model.state.value.network) + assertNull(model.state.value.address) + } + + @Test + fun `overlapping history pages replace stale deposits and retain their new offset`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf())) + val first = UsdtDeposit("first", "tron", "USDT", 100u, "tx1", "held", null, null) + val second = first.copy(id = "second", sourceTx = "tx2") + val updated = second.copy(status = "completed") + whenever(repo.depositHistory(0u)).thenReturn(Result.success(UsdtDepositPage(listOf(first, second), 50u))) + whenever(repo.depositHistory(50u)).thenReturn(Result.success(UsdtDepositPage(listOf(updated), null))) + whenever(repo.depositDetail("second", 50u)).thenReturn(Result.success(UsdtDepositDetail(updated, null))) + val model = UsdtDepositViewModel(repo) + model.history().join() + model.history(50u).join() + assertEquals(listOf(first, updated), model.state.value.deposits) + assertNull(model.state.value.nextOffset) + model.detail("second") + assertEquals(50u, model.state.value.offset) + assertEquals(updated, model.state.value.detail?.deposit) + } + + @Test + fun `linked order status and error supersede deposit state together`() { + val deposit = UsdtDeposit("deposit", "tron", "USDT", 100u, "tx", "held", "standing_route_unavailable", null) + val completed = UsdtDepositOrder("completed", 100u, 90u, "destination-tx", null, null) + val cases = listOf( + Triple(completed, R.string.usdt__confirmed, null), + Triple(completed.copy(status = "refunded"), R.string.usdt__deposit_refunded, null), + Triple(null, R.string.usdt__deposit_needs_attention, deposit.code), + Triple(completed.copy(status = "unknown"), R.string.usdt__deposit_needs_attention, null), + Triple(completed.copy(status = "pending"), R.string.usdt__pending, null), + Triple(completed.copy(status = "processing"), R.string.usdt__pending, null), + Triple(completed.copy(status = "refund_requested"), R.string.usdt__deposit_refunding, null), + Triple( + completed.copy(status = "failed", code = "order_error"), + R.string.usdt__deposit_needs_attention, + "order_error" + ), + ) + cases.forEach { (order, expectedStatus, expectedCode) -> + val detail = UsdtDepositDetail(deposit, order) + assertEquals(expectedStatus, detail.statusResource()) + assertEquals(expectedCode, detail.statusCode) + } + } + + @Test + fun `deposit status distinguishes refund progress from actionable errors`() { + val cases = listOf( + Triple("completed", "standing_route_unavailable", R.string.usdt__confirmed), + Triple("refunded", "xchain_amount_too_small", R.string.usdt__deposit_refunded), + Triple("refund_requested", "standing_refund_pending", R.string.usdt__deposit_refunding), + Triple("refunding", "standing_refund_pending", R.string.usdt__deposit_refunding), + Triple( + "refund_requested", + "standing_tron_refund_requires_operator", + R.string.usdt__deposit_needs_attention, + ), + Triple("refunding", "refund_error", R.string.usdt__deposit_needs_attention), + Triple("funded", null, R.string.usdt__pending), + Triple("confirming", null, R.string.usdt__pending), + Triple("swapping", null, R.string.usdt__pending), + Triple("bridging", null, R.string.usdt__pending), + Triple("delivering", null, R.string.usdt__pending), + Triple("swapping", "order_error", R.string.usdt__deposit_needs_attention), + Triple("pending", "standing_route_unavailable", R.string.usdt__deposit_needs_attention), + Triple("below_minimum", "xchain_amount_too_small", R.string.usdt__deposit_needs_attention), + ) + cases.forEach { (status, code, expected) -> + val deposit = UsdtDeposit("deposit", "bsc", "USDT", 100u, "tx", status, code, null) + val order = UsdtDepositOrder(status, 100u, 90u, null, null, code) + listOf(null, order).forEach { linkedOrder -> + val detail = UsdtDepositDetail(deposit, linkedOrder) + assertEquals(expected, detail.statusResource(), status) + assertEquals(code, detail.statusCode, status) + } + } + } + + @Test + fun `deposit source names resolve to every supported network`() { + UsdtDepositNetwork.entries.forEach { network -> + assertEquals(network, network.name.lowercase().depositNetwork()) + } + assertNull("unsupported".depositNetwork()) + } + + @Test + fun `refund eligibility follows supported asset network and current order`() { + val deposit = UsdtDeposit("deposit", "bsc", "USDT", 100u, "tx", "held", null, null) + val detail = UsdtDepositDetail(deposit, null) + assertTrue(detail.canRefund) + listOf("completed", "refunded", "refunding", "refund_requested").forEach { status -> + assertFalse(detail.copy(deposit = deposit.copy(status = status)).canRefund) + assertFalse(detail.copy(order = UsdtDepositOrder(status, 100u, null, null, null, null)).canRefund) + } + assertFalse(detail.copy(deposit = deposit.copy(asset = "USDC")).canRefund) + assertFalse(detail.copy(deposit = deposit.copy(network = "unknown")).canRefund) + } + + @Test + fun `returning to history shows the refreshed order status and refund acknowledgment`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf())) + val deposit = UsdtDeposit("deposit", "bsc", "USDT", 100u, "tx", "held", "stale", null) + val order = UsdtDepositOrder("delivering", 100u, null, null, null, null) + whenever(repo.depositHistory(0u)).thenReturn(Result.success(UsdtDepositPage(listOf(deposit), null))) + whenever(repo.depositDetail("deposit", 0u)).thenReturn(Result.success(UsdtDepositDetail(deposit, order))) + whenever(repo.refundDeposit("deposit", 0u, "address", UsdtDepositNetwork.BSC)).thenReturn(Result.success(Unit)) + val model = UsdtDepositViewModel(repo) + model.history().join() + model.detail("deposit") + assertEquals("delivering", model.state.value.deposits.single().status) + assertNull(model.state.value.deposits.single().code) + model.refund("deposit", 0u, "address", UsdtDepositNetwork.BSC).join() + assertEquals("refund_requested", model.state.value.deposits.single().status) + model.back() + assertEquals( + R.string.usdt__deposit_refunding, + depositStatus( + model.state.value.deposits.single().status, + model.state.value.deposits.single().code, + ) + ) + } + + @Test + fun `receive request locks network and navigation until cancellation`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf(UsdtDepositNetwork.TRON))) + val result = CompletableDeferred>() + whenever(repo.prepareDeposit(UsdtDepositNetwork.TRON, "100")).doSuspendableAnswer { result.await() } + val model = UsdtDepositViewModel(repo) + model.selectNetwork(UsdtDepositNetwork.TRON) + val request = model.prepare("100") + assertTrue(model.state.value.busy) + model.selectNetwork(UsdtDepositNetwork.ETHEREUM) + model.prepare("100").join() + assertEquals(UsdtDepositNetwork.TRON, model.state.value.network) + assertTrue(model.back()) + verify(repo, times(1)).prepareDeposit(UsdtDepositNetwork.TRON, "100") + request.cancelAndJoin() + assertFalse(model.state.value.busy) + assertNull(model.state.value.address) + assertNull(model.state.value.error) + assertFalse(model.back()) + } + + @Test + fun `provider refund acknowledgment stays a request and keeps history intact`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf())) + whenever(repo.depositHistory(50u)).thenReturn(Result.success(UsdtDepositPage(emptyList(), null))) + whenever(repo.refundDeposit("deposit", 50u, "source-address", UsdtDepositNetwork.TRON)) + .thenReturn(Result.success(Unit)) + val deposit = UsdtDeposit("deposit", "tron", "USDT", 100u, "source-tx", "held", null, null) + whenever(repo.depositDetail("deposit", 50u)).thenReturn(Result.success(UsdtDepositDetail(deposit, null))) + val model = UsdtDepositViewModel(repo) + model.history(50u).join() + model.detail("deposit") + model.refund("deposit", 50u, "source-address", UsdtDepositNetwork.TRON).join() + assertEquals(50u, model.state.value.offset) + assertTrue(model.state.value.history) + assertEquals(R.string.usdt__deposit_refund_requested, model.state.value.message) + assertEquals("refund_requested", model.state.value.detail?.deposit?.status) + model.refund("deposit", 50u, "source-address", UsdtDepositNetwork.TRON).join() + verify(repo, times(1)).refundDeposit("deposit", 50u, "source-address", UsdtDepositNetwork.TRON) + assertFalse(model.state.value.busy) + assertTrue(model.back()) + assertTrue(model.back()) + assertFalse(model.state.value.history) + } + + @Test + fun `completed refund clears pending acknowledgment after refresh`() = test { + whenever(repo.depositNetworks()).thenReturn(Result.success(persistentListOf())) + whenever(repo.refundDeposit("deposit", 0u, "source-address", UsdtDepositNetwork.BSC)) + .thenReturn(Result.success(Unit)) + val deposit = UsdtDeposit("deposit", "bsc", "USDT", 100u, "source-tx", "held", "xchain_amount_too_small", null) + val pending = deposit.copy(status = "refund_requested", code = "standing_refund_pending") + val refunded = deposit.copy(status = "refunded", refundTx = "refund-tx") + whenever(repo.depositDetail("deposit", 0u)).thenReturn( + Result.success(UsdtDepositDetail(deposit, null)), + Result.success(UsdtDepositDetail(pending, null)), + Result.success(UsdtDepositDetail(refunded, null)), + ) + val model = UsdtDepositViewModel(repo) + model.detail("deposit") + model.refund("deposit", 0u, "source-address", UsdtDepositNetwork.BSC).join() + model.detail("deposit") + assertEquals(R.string.usdt__deposit_refund_requested, model.state.value.message) + assertEquals(R.string.usdt__deposit_refunding, model.state.value.detail?.statusResource()) + model.detail("deposit") + assertNull(model.state.value.message) + assertEquals(R.string.usdt__deposit_refunded, model.state.value.detail?.statusResource()) + assertEquals("refund-tx", model.state.value.detail?.deposit?.refundTx) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportDetailsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportDetailsTest.kt new file mode 100644 index 0000000000..916b410113 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtSupportDetailsTest.kt @@ -0,0 +1,63 @@ +package to.bitkit.ui.screens.wallets.usdt + +import com.synonym.bitkitcore.UsdtDeposit +import com.synonym.bitkitcore.UsdtDepositDetail +import com.synonym.bitkitcore.UsdtDepositOrder +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtOrchestraTransfer +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UsdtSupportDetailsTest { + @Test + fun `transfer support includes provider references and transactions`() { + val transfer = UsdtTransfer( + sender = null, + id = "local-id", + txHash = "source-tx", + userOperationHash = null, + bridgeGuid = "message-id", + orchestra = null, + recipient = "recipient-address", + destination = UsdtDestination.POLYGON, + amount = 100u, + receivedAmount = 90u, + fee = 10u, + isIncoming = false, + status = UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION, + timestamp = 1u, + ) + assertTrue(transfer.supportDetails.contains("Bridge: USDT0")) + assertTrue(transfer.supportDetails.contains("LayerZero message: message-id")) + assertFalse(transfer.supportDetails.contains("Destination transaction:")) + + val details = transfer.copy( + bridgeGuid = null, + orchestra = UsdtOrchestraTransfer("order-id", "funding-address", "destination-tx", "refund-tx", 90u), + ).supportDetails + listOf("Orchestra", "Arbitrum One", "Polygon", "order-id", "source-tx", "destination-tx", "refund-tx").forEach { + assertTrue(details.contains(it), it) + } + assertFalse(details.contains("recipient-address")) + assertFalse(details.contains("funding-address")) + } + + @Test + fun `deposit support uses current order status and refund`() { + val deposit = UsdtDeposit("deposit-id", "tron", "USDT", 100u, "source-tx", "held", "old-code", "deposit-refund") + val detail = UsdtDepositDetail(deposit, null) + assertTrue(detail.supportDetails.contains("Status code: old-code")) + assertTrue(detail.supportDetails.contains("Refund transaction: deposit-refund")) + val details = detail.copy( + order = UsdtDepositOrder("refunded", 100u, null, null, "order-refund", null), + ).supportDetails + listOf("Orchestra", "tron", "Arbitrum One", "deposit-id", "source-tx", "refunded", "order-refund").forEach { + assertTrue(details.contains(it), it) + } + assertFalse(details.contains("old-code")) + assertFalse(details.contains("deposit-refund")) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt new file mode 100644 index 0000000000..e429b4dd5c --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt @@ -0,0 +1,496 @@ +package to.bitkit.ui.screens.wallets.usdt + +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.usdtFormatAmount +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +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.ext.nowTimestamp +import to.bitkit.repositories.UsdtRepo +import to.bitkit.repositories.UsdtSelfSendError +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.repositories.parseUsdtPaymentRequest +import to.bitkit.test.BaseUnitTest +import to.bitkit.viewmodels.SanityWarning +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UsdtViewModelTest : BaseUnitTest() { + @Test + fun `settled payment rejects replacement quotes until explicitly edited`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + viewModel.waitForTransfer() + viewModel.quote(quote.recipient, "2", quote.destination).join() + viewModel.quoteMaximum(quote.recipient, quote.destination) { + error("Submitted payment must stay terminal") + }.join() + viewModel.confirm().join() + assertTrue(viewModel.state.value.submitted) + assertEquals(quote, viewModel.state.value.quote) + verify(repo, times(1)).quote(any(), any(), any(), any()) + verify(repo, never()).quoteMaximum(any(), any()) + verify(repo, times(1)).send(any(), anyOrNull(), any()) + viewModel.edit() + viewModel.quote(quote.recipient, "1", quote.destination).join() + verify(repo, times(2)).quote(any(), any(), any(), any()) + } + + @Test + fun `detached Paykit handler cannot fall back to raw send after authentication`() = test { + val viewModel = createViewModel() + viewModel.sendPayment = { error("Detached handler must not run") } + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + viewModel.sendPayment = null + viewModel.authenticationVerified() + assertEquals(R.string.wallet__payment_request_unavailable, viewModel.state.value.error) + assertFalse(viewModel.state.value.submitted) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + + @Test + fun `detached Paykit handler cannot fall back while payment settings load`() = test { + val viewModel = createViewModel() + val delayed = MutableSharedFlow() + whenever(settingsStore.data).thenReturn(delayed) + viewModel.sendPayment = { error("Detached handler must not run") } + viewModel.quote(quote.recipient, "1", quote.destination).join() + val confirmation = viewModel.confirm() + viewModel.sendPayment = null + delayed.emit(SettingsData(isPinEnabled = false)) + confirmation.join() + assertEquals(R.string.wallet__payment_request_unavailable, viewModel.state.value.error) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + + @Test + fun `fee collection failures offer an actionable message without classifying other rejections`() { + assertEquals( + R.string.usdt__error_fee_collection, + UsdtException.TransactionRejected("Paymaster fee collection failed").messageResource(), + ) + assertEquals( + R.string.usdt__error_rejected, + UsdtException.TransactionRejected("Provider rejected the request").messageResource(), + ) + assertEquals(R.string.usdt__error_balance, UsdtException.InsufficientBalance().messageResource()) + assertEquals(R.string.usdt__error_self_send, UsdtSelfSendError().messageResource()) + assertEquals(R.string.usdt__error_address, UsdtException.InvalidAddress().messageResource()) + } + + @Test + fun `bridge cost rejection prevents payment review and keeps the amount editable`() = test { + val viewModel = createViewModel() + whenever(repo.quote(eq(quote.recipient), eq("3.5"), eq(UsdtDestination.TRON), any())) + .thenReturn(Result.failure(UsdtException.BridgeAmountTooSmall())) + viewModel.quote(quote.recipient, "3.5", UsdtDestination.TRON).join() + assertEquals(R.string.usdt__error_bridge_amount, viewModel.state.value.error) + assertNull(viewModel.state.value.quote) + assertFalse(viewModel.state.value.busy) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + + private val repo: UsdtRepo = mock() + private val settingsStore: SettingsStore = mock() + private val walletState = MutableStateFlow(UsdtWalletState()) + private val settings = MutableStateFlow(SettingsData(isPinEnabled = true, isPinForPaymentsEnabled = true)) + private val quote = UsdtQuote( + id = "payment", + recipient = "0x1111111111111111111111111111111111111111", + destination = UsdtDestination.ARBITRUM, + bridgeProvider = null, + amount = 1_000_000u, + receivedAmount = 1_000_000u, + maximumFee = 20u, + expiresAt = nowTimestamp().epochSecond.toULong() + 120u, + ) + + @Test + fun `recipient errors prevent advancing until a valid address is parsed`() = test { + val viewModel = createViewModel() + val parser = Mockito.mockStatic(Class.forName("to.bitkit.repositories.UsdtRepoKt")) + parser.`when`> { parseUsdtPaymentRequest("invalid", UsdtDestination.ARBITRUM) } + .thenReturn(Result.failure(UsdtException.InvalidAddress())) + for (destination in listOf(UsdtDestination.ARBITRUM, UsdtDestination.POLYGON)) { + parser.`when`> { parseUsdtPaymentRequest("valid request", destination) } + .thenReturn(Result.success(UsdtPaymentRequest(quote.recipient, 1_500_000u, 42161u))) + } + try { + var request: UsdtPaymentRequest? = null + viewModel.validateRecipient("invalid", UsdtDestination.ARBITRUM) { request = it } + assertNull(request) + assertEquals(R.string.usdt__error_address, viewModel.state.value.error) + viewModel.validateRecipient("valid request", UsdtDestination.POLYGON) { request = it } + assertEquals(42161uL, request?.chainId) + assertNull(viewModel.state.value.error) + viewModel.validateRecipient("valid request", UsdtDestination.ARBITRUM) { request = it } + assertEquals(quote.recipient, request?.recipient) + assertEquals(1_500_000uL, request?.amount) + verify(repo, never()).quote(any(), any(), any(), any()) + verify(repo, never()).send(any(), anyOrNull(), any()) + assertNull(viewModel.state.value.error) + } finally { parser.close() } + } + + @Test + fun `returning to unchanged payment review reuses its approved quote`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) + val fresh = quote.copy(expiresAt = nowTimestamp().epochSecond.toULong() + 120u) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(fresh)) + + viewModel.quote(fresh.recipient, "1", fresh.destination).join() + viewModel.edit() + viewModel.quote(fresh.recipient, "1", fresh.destination).join() + + assertEquals(fresh, viewModel.state.value.quote) + verify(repo, times(1)).quote(any(), any(), any(), any()) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + + @Test + fun `closing send discards its quote before another payment flow`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) + viewModel.isSendPresented = true + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.edit() + viewModel.isSendPresented = false + viewModel.isSendPresented = true + viewModel.quote(quote.recipient, "1", quote.destination).join() + + verify(repo, times(2)).quote(any(), any(), any(), any()) + } + + @Test + fun `changed amount recipient or network requires another quote`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) + val fresh = quote.copy(expiresAt = nowTimestamp().epochSecond.toULong() + 120u) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(fresh)) + viewModel.quote(fresh.recipient, "1", fresh.destination).join() + viewModel.edit() + viewModel.quote(fresh.recipient, "2", fresh.destination).join() + viewModel.edit() + viewModel.quote("other recipient", "2", fresh.destination).join() + viewModel.edit() + viewModel.quote(fresh.recipient, "2", UsdtDestination.POLYGON).join() + + verify(repo, times(4)).quote(any(), any(), any(), any()) + } + + @Test + fun `changing wallets prevents reuse for the same recipient amount and network`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) + val fresh = quote.copy(expiresAt = nowTimestamp().epochSecond.toULong() + 120u) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(fresh)) + viewModel.quote(fresh.recipient, "1", fresh.destination).join() + viewModel.edit() + walletState.value = UsdtWalletState(address = "another wallet") + viewModel.quote(fresh.recipient, "1", fresh.destination).join() + + verify(repo, times(2)).quote(any(), any(), any(), any()) + } + + @Test + fun `expiring quote is replaced and a failed refresh never displays stale fees`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) + val expiring = quote.copy(expiresAt = nowTimestamp().epochSecond.toULong() + 4u) + whenever(repo.quote(any(), any(), any(), any())) + .thenReturn(Result.success(expiring), Result.failure(UsdtException.RateLimited()), Result.success(quote)) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.edit() + viewModel.quote(quote.recipient, "1", quote.destination).join() + assertNull(viewModel.state.value.quote) + assertFalse(viewModel.state.value.busy) + viewModel.quote(quote.recipient, "1", quote.destination).join() + assertEquals(quote, viewModel.state.value.quote) + verify(repo, times(3)).quote(any(), any(), any(), any()) + } + + @Test + fun `submission failure invalidates a reusable quote before retry`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + val fresh = quote.copy(expiresAt = nowTimestamp().epochSecond.toULong() + 120u) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(fresh)) + whenever(repo.send(eq(fresh), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.SubmissionDeadlineExpired())) + viewModel.quote(fresh.recipient, "1", fresh.destination).join() + viewModel.confirm().join() + assertNull(viewModel.state.value.quote) + viewModel.quote(fresh.recipient, "1", fresh.destination).join() + verify(repo, times(2)).quote(any(), any(), any(), any()) + verify(repo).send(eq(fresh), anyOrNull(), any()) + } + + @Test + fun `maximum amount reuses its funded quote only until submission`() = test { + val viewModel = createViewModel() + val maximum = quote.copy(amount = 900_000u, expiresAt = nowTimestamp().epochSecond.toULong() + 3600u) + whenever(repo.quoteMaximum(quote.recipient, quote.destination)).thenReturn(Result.success(maximum)) + Mockito.mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { core -> + core.`when` { usdtFormatAmount(maximum.amount) }.thenReturn("0.9") + var amount: String? = null + + viewModel.quoteMaximum(quote.recipient, quote.destination) { amount = it }.join() + + assertEquals("0.9", amount) + assertFalse(viewModel.state.value.busy) + assertFalse(viewModel.state.value.preparingMaximum) + assertNull(viewModel.state.value.quote) + viewModel.quote(quote.recipient, "0.9", quote.destination).join() + assertEquals(maximum, viewModel.state.value.quote) + verify(repo, never()).quote(any(), any(), any(), any()) + verify(repo, never()).send(any(), anyOrNull(), any()) + + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(maximum), anyOrNull(), any())).thenReturn(Result.success(mock())) + viewModel.confirm().join() + verify(repo).send(eq(maximum), anyOrNull(), any()) + viewModel.waitForTransfer() + viewModel.edit() + viewModel.quote(quote.recipient, "0.9", quote.destination).join() + verify(repo).quote(eq(quote.recipient), eq("0.9"), eq(quote.destination), any()) + } + } + + @Test + fun `maximum quote failure preserves the entered amount and permits retry`() = test { + val viewModel = createViewModel() + whenever(repo.quoteMaximum(quote.recipient, quote.destination)) + .thenReturn(Result.failure(UsdtException.InsufficientBalance())) + var amount = "0.5" + + viewModel.quoteMaximum(quote.recipient, quote.destination) { amount = it }.join() + + assertEquals("0.5", amount) + assertEquals(R.string.usdt__error_balance, viewModel.state.value.error) + assertFalse(viewModel.state.value.busy) + assertFalse(viewModel.state.value.preparingMaximum) + assertNull(viewModel.state.value.quote) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + + @Test + fun `payment authentication cannot be skipped or reused after cancellation`() = test { + val viewModel = createViewModel() + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + assertTrue(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + + viewModel.cancelAuthentication() + viewModel.authenticationVerified() + verify(repo, never()).send(any(), anyOrNull(), any()) + + viewModel.confirm().join() + viewModel.authenticationVerified() + viewModel.authenticationVerified() + viewModel.confirm().join() + verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + assertTrue(viewModel.state.value.submitted) + assertTrue(viewModel.state.value.busy) + viewModel.waitForTransfer() + assertFalse(viewModel.state.value.busy) + } + + @Test + fun `failed submission clears the quote and never reports success`() = test { + val viewModel = createViewModel() + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.SubmissionDeadlineExpired())) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + assertFalse(viewModel.state.value.authenticationRequired) + assertFalse(viewModel.state.value.submitted) + assertFalse(viewModel.state.value.busy) + assertNull(viewModel.state.value.quote) + assertEquals(R.string.wallet__payment_request_expired, viewModel.state.value.error) + viewModel.confirm().join() + verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + } + + @Test + fun `durably recorded send failure retains the payment and prevents resubmission`() = test { + val retained: UsdtTransfer = mock() + whenever(retained.id).thenReturn(quote.id) + val viewModel = createViewModel(walletState = UsdtWalletState(transfers = persistentListOf(retained))) + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.BackupUnavailable())) + viewModel.quote(quote.recipient, "1", quote.destination).join() + + viewModel.confirm().join() + + assertTrue(viewModel.state.value.submitted) + assertTrue(viewModel.state.value.busy) + assertEquals(quote, viewModel.state.value.quote) + assertNull(viewModel.state.value.error) + viewModel.waitForTransfer() + assertFalse(viewModel.state.value.busy) + viewModel.confirm().join() + verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + } + + @Test + fun `deducted bridge costs require the high fee warning`() = test { + val model = createViewModel(walletState = UsdtWalletState(balance = 100_000_000u)) + val bridge = quote.copy(amount = 5_000_000u, receivedAmount = 2_000_000u, maximumFee = 100u) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(bridge)) + model.quote(bridge.recipient, "5", bridge.destination).join() + model.confirm().join() + assertEquals(SanityWarning.FEE_OVER_HALF_VALUE, model.state.value.warning) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + + @Test + fun `recorded payment that exceeds its deadline retains recovery with an expiry error`() = test { + val retained: UsdtTransfer = mock { on { id }.thenReturn(quote.id) } + val model = createViewModel(walletState = UsdtWalletState(transfers = persistentListOf(retained))) + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.SubmissionDeadlineExpired())) + model.quote(quote.recipient, "1", quote.destination).join() + model.confirm().join() + assertTrue(model.state.value.submitted) + assertEquals(quote, model.state.value.quote) + assertEquals(R.string.wallet__payment_request_expired, model.state.value.error) + model.confirm().join() + verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + } + + @Test + fun `amount warnings require acknowledgment before payment authentication`() = test { + val viewModel = createViewModel() + val large = quote.copy(amount = 200_000_000u, receivedAmount = 200_000_000u) + settings.value = settings.value.copy(enableSendAmountWarning = true) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(large)) + viewModel.quote(large.recipient, "200", large.destination).join() + viewModel.confirm().join() + assertEquals(SanityWarning.VALUE_OVER_100_USD, viewModel.state.value.warning) + assertFalse(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + viewModel.acceptWarning() + assertNull(viewModel.state.value.warning) + assertTrue(viewModel.state.value.authenticationRequired) + viewModel.edit() + assertTrue(viewModel.state.value.confirmedWarnings.isEmpty()) + } + + @Test + fun `editing while settings load cannot authorize a replacement quote`() = test { + val viewModel = createViewModel() + val delayed = MutableSharedFlow() + whenever(settingsStore.data).thenReturn(delayed) + viewModel.quote(quote.recipient, "1", quote.destination).join() + val confirmation = viewModel.confirm() + assertTrue(viewModel.state.value.busy) + viewModel.edit() + val replacement = quote.copy(id = "replacement", amount = 2_000_000u) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(replacement)) + viewModel.quote(replacement.recipient, "2", replacement.destination).join() + delayed.emit(SettingsData(isPinEnabled = false)) + confirmation.join() + verify(repo, never()).send(any(), anyOrNull(), any()) + assertEquals("replacement", viewModel.state.value.quote?.id) + } + + @Test + fun `settings failures keep balances private and block confirmation until settings recover`() = test { + var failing = true + val viewModel = createViewModel( + flow { + if (failing) throw IOException("settings unavailable") + emit(settings.value) + } + ) + val collector = backgroundScope.launch { viewModel.settings.collect {} } + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + assertTrue(viewModel.settings.value.hideBalance) + assertEquals(R.string.usdt__error_storage, viewModel.state.value.error) + assertFalse(viewModel.state.value.busy) + assertFalse(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + + failing = false + viewModel.confirm().join() + assertNull(viewModel.state.value.error) + assertTrue(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + collector.cancel() + } + + @Test + fun `payment settings never authorize a refund on read failure or cancellation`() = test { + val viewModel = createViewModel() + whenever(settingsStore.data).thenReturn(flow { throw IOException("settings unavailable") }) + var refundAuthorized = false + viewModel.readPaymentSettings { refundAuthorized = true } + assertFalse(refundAuthorized) + assertEquals(R.string.usdt__error_storage, viewModel.state.value.error) + + viewModel.edit() + whenever(settingsStore.data).thenReturn(flow { throw CancellationException() }) + assertFailsWith { + viewModel.readPaymentSettings { refundAuthorized = true } + } + assertFalse(refundAuthorized) + assertNull(viewModel.state.value.error) + } + + @Test + fun `cancelled execution wait keeps the submitted payment and releases the swipe`() = test { + val viewModel = createViewModel() + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + whenever(repo.waitForTransfer(quote.id)).thenThrow(CancellationException()) + assertFailsWith { viewModel.waitForTransfer() } + assertTrue(viewModel.state.value.submitted) + assertFalse(viewModel.state.value.busy) + assertEquals(quote, viewModel.state.value.quote) + assertNull(viewModel.state.value.error) + } + + private suspend fun createViewModel( + settingsFlow: Flow = settings, + walletState: UsdtWalletState = UsdtWalletState(), + ): UsdtViewModel { + this.walletState.value = walletState + whenever(repo.state).thenReturn(this.walletState) + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(quote)) + whenever(repo.send(eq(quote), anyOrNull(), any())).thenReturn(Result.success(mock())) + whenever(repo.refresh(false)).thenReturn(Result.success(Unit)) + whenever(repo.waitForTransfer(quote.id)).thenReturn(Result.success(Unit)) + return UsdtViewModel(repo, settingsStore) + } +} diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt index afe57d77bd..8175acd216 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt @@ -76,7 +76,7 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { ) ) ) - whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any()) } + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any()) } .thenReturn(Result.success(Unit)) } diff --git a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt new file mode 100644 index 0000000000..8d84413aef --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -0,0 +1,68 @@ +package to.bitkit.ui.sheets + +import android.content.Context +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.composable +import androidx.navigation.createGraph +import androidx.test.core.app.ApplicationProvider +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.ui.navigateTo +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SendSheetNavigationTest { + @Test + fun `pending clears earlier send screens after prepared amount review`() { + for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect, SendRoute.Amount)) { + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = start) { + composable {} + composable {} + composable {} + composable {} + } + } + navController.navigateToSendAmount(start) + navController.navigateTo(SendRoute.Confirm) + navController.navigateToSendPending( + SendRoute.Pending(paymentHash = "payment", amount = 1000, walletId = "wallet0") + ) + assertNull(navController.previousBackStackEntry) + navController.popBackStack() + assertNull(navController.currentDestination) + } + } + + @Test + fun `prepared requests discard confirmation while contact payments retain their previous screen`() { + for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect)) { + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = start) { + composable {} + composable {} + composable {} + composable {} + } + } + navController.navigateToSendAmount(start) + navController.navigateTo(SendRoute.UsdtReview) + assertTrue(navController.popBackStack()) + assertTrue(navController.currentDestination?.hasRoute() == true) + navController.popBackStack() + if (start == SendRoute.Confirm) { + assertNull(navController.currentDestination) + } else { + assertTrue(navController.currentDestination?.hasRoute() == true) + } + } + } +} diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index fc7f2d144b..8063b14a52 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -31,6 +31,7 @@ import to.bitkit.repositories.LightningState import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.UsdtRepo import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService @@ -47,6 +48,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val backupRepo = mock() private val keychain = mock() private val coreService = mock() + private val usdtRepo = mock() private val db = mock() private val settingsStore = mock() private val cacheStore = mock() @@ -72,6 +74,10 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Before fun setUp() { whenever { lightningRepo.stop() }.thenReturn(Result.success(Unit)) + whenever { usdtRepo.wipe(any()) }.doSuspendableAnswer { + it.getArgument Unit>(0).invoke() + Result.success(Unit) + } whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn(Result.success(Unit)) @@ -85,6 +91,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { backupRepo = backupRepo, keychain = keychain, coreService = coreService, + usdtRepo = usdtRepo, db = db, settingsStore = settingsStore, cacheStore = cacheStore, @@ -115,6 +122,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { backupRepo, keychain, coreService, + usdtRepo, db, settingsStore, cacheStore, @@ -141,6 +149,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(migrationService).cleanupAfterMigration() inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(pubkyRepo).wipeLocalState() + inOrder.verify(usdtRepo).wipe(any()) inOrder.verify(keychain).wipe() inOrder.verify(coreService).wipeData() inOrder.verify(db).clearAllTables() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 957c2387b5..2cf357fd10 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -19,7 +19,14 @@ import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.OnChainInvoice import com.synonym.bitkitcore.Scanner +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtPaymentRequest +import com.synonym.bitkitcore.UsdtQuote import com.synonym.bitkitcore.ValidationResult +import com.synonym.paykit.ConversionRate +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentConversionQuoteRecord import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf @@ -49,6 +56,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import org.junit.After import org.junit.Before import org.junit.Test @@ -59,10 +67,12 @@ import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.SyncType import org.lightningdevkit.ldknode.TransactionDetails +import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn @@ -93,6 +103,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.env.Env import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.fromHex import to.bitkit.ext.runSuspendCatching @@ -107,6 +118,9 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest @@ -124,6 +138,8 @@ import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.Endpoint import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.IncomingPaykitPaymentRequestFailureReason @@ -155,6 +171,7 @@ import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.PaykitSubscriptionMetadata import to.bitkit.repositories.PaykitSubscriptionRecurrence +import to.bitkit.repositories.PaykitUsdtPaymentRepo import to.bitkit.repositories.PaymentAbortedBeforeSend import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -175,9 +192,13 @@ import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress import to.bitkit.repositories.SettledReceiveInvoice import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.UsdtPreferencesRepo +import to.bitkit.repositories.UsdtRepo +import to.bitkit.repositories.UsdtWalletState import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState import to.bitkit.repositories.WidgetsRepo +import to.bitkit.repositories.parseUsdtPaymentRequest import to.bitkit.services.ActivityService import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService @@ -256,6 +277,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paykitPaymentRequestRepo = mock() private val paykitPaymentProofRepo = mock() private val paykitPaymentRequestDiagnostics = mock() + private val usdtRepo = mock() + private val usdtState = MutableStateFlow(UsdtWalletState(balance = 1_000_000u)) + private val paykitUsdtPayments = mock() private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() @@ -298,6 +322,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val legacyDirectSignupAuthUrl = "pubkyauth://signup?hs=homeserver&st=invite" private val grantSignupAuthUrl = "pubkyauth://signup_grant?hs=homeserver&st=invite&cid=shop.pubky.app" + private val usdtEnabled = MutableStateFlow(true) + private val usdtPreferences: UsdtPreferencesRepo = mock { + on { enabled }.thenReturn(usdtEnabled) + on { visible }.thenReturn(flowOf(true)) + } private val timedSheetManager = mock() private val timedSheetType = MutableStateFlow(null) @@ -322,6 +351,170 @@ class AppViewModelSendFlowTest : BaseUnitTest() { App.currentActivity = null } + @Test + fun `new USDT action waits for explicit enable and resumes exactly once`() = test { + usdtEnabled.value = false + runCurrent() + var continuations = 0 + sut.requestUsdtEnable { continuations++ } + assertTrue(sut.showUsdtEnablePrompt) + assertEquals(0, continuations) + sut.dismissUsdtEnablePrompt() + sut.confirmUsdtEnable() + runCurrent() + assertEquals(0, continuations) + + whenever(usdtPreferences.setEnabled(true)).doSuspendableAnswer { + usdtEnabled.value = true + Result.success(Unit) + } + sut.requestUsdtEnable { continuations++ } + sut.confirmUsdtEnable() + runCurrent() + assertEquals(1, continuations) + assertFalse(sut.showUsdtEnablePrompt) + sut.confirmUsdtEnable() + runCurrent() + assertEquals(1, continuations) + } + + @Test + fun `disabled USDT clipboard is ignored while explicit scans can enable and resume`() = test { + val recipient = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" + val uri = "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=$recipient" + usdtEnabled.value = false + runCurrent() + Mockito.mockStatic(Class.forName("to.bitkit.repositories.UsdtRepoKt")).use { parser -> + for (input in listOf(recipient, uri)) { + parser.`when`> { parseUsdtPaymentRequest(input, UsdtDestination.ARBITRUM) } + .thenReturn(Result.success(UsdtPaymentRequest(recipient, 50_000u, 42161u))) + assertFalse(sut.canDecodeClipboard(input)) + sut.mainScreenEffect.test { + sut.onClipboardAutoRead(input) + runCurrent() + expectNoEvents() + sut.onScanResult(input) + advanceUntilIdle() + assertTrue(sut.showUsdtEnablePrompt) + expectNoEvents() + sut.dismissUsdtEnablePrompt() + runCurrent() + expectNoEvents() + } + } + whenever(usdtPreferences.setEnabled(true)).doSuspendableAnswer { + usdtEnabled.value = true + Result.success(Unit) + } + sut.mainScreenEffect.test { + sut.onScanResult(uri) + advanceUntilIdle() + sut.confirmUsdtEnable() + advanceUntilIdle() + assertEquals(MainScreenEffect.Navigate(Routes.UsdtPayment(recipient, "0.05")), awaitItem()) + assertTrue(sut.canDecodeClipboard(uri)) + } + } + verify(coreService, never()).decode(any()) + } + + @Test + fun `declining a USDT-only contact releases the payment without a failure or retry`() = test { + usdtEnabled.value = false + runCurrent() + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") + sut.openContactPayment(endpoint.value, testPublicKey, endpoints = listOf(endpoint)) + advanceUntilIdle() + assertTrue(sut.showUsdtEnablePrompt) + sut.dismissUsdtEnablePrompt() + runCurrent() + assertNull(activeContactPaymentContext()) + assertNull(sut.currentSheet.value) + assertFalse(sut.showUsdtEnablePrompt) + } + + @Test + fun `subscription first payment waits for enable and declining leaves it unpaid without an error`() = test { + usdtEnabled.value = false + runCurrent() + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") + val subscription = subscriptionStartingAt(Clock.System.now() + 60.seconds) + val request = paymentRequest().copy( + paymentRequestId = subscription.paymentRequestId, + amount = PaykitAmount(PaykitAsset.USDT, 50_000u), + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + billingPeriod = PaykitBillingPeriod(Clock.System.now(), Clock.System.now() + 60.seconds), + ) + paykitSubscriptions.value = listOf(subscription) + whenever(paykitPaymentRequestRepo.accept(subscription)).thenReturn(Result.success(request)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.Opened(endpoint.value, endpoints = listOf(endpoint))) + ) + sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + advanceUntilIdle() + assertTrue(sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow()) + advanceUntilIdle() + assertTrue(sut.showUsdtEnablePrompt) + assertFalse(sut.currentSheet.value is Sheet.Send) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + sut.dismissUsdtEnablePrompt() + runCurrent() + assertNull(activeContactPaymentContext()) + assertNull(sut.currentSheet.value) + assertFalse(sut.showUsdtEnablePrompt) + } + + @Test + fun `receiving methods update when a restored wallet gains funds and becomes empty`() = test { + usdtState.value = UsdtWalletState(balance = 0u) + whenever(paykitPaymentRequestRepo.acceptedPaymentEndpointIdentifiers(any())).thenAnswer { + if (usdtState.value.balance == 0uL) emptyList() else listOf(MethodId.UsdtArbitrum.rawValue) + } + sut.paykitReceivingMethods.test { + assertEquals(emptyList(), awaitItem()) + runCurrent() + usdtState.value = UsdtWalletState(balance = 50_000u) + assertEquals(listOf(MethodId.UsdtArbitrum.rawValue), awaitItem()) + usdtState.value = UsdtWalletState(balance = 0u) + assertEquals(emptyList(), awaitItem()) + } + } + + @Test + fun `USDT opt-out preserves Bitcoin and Lightning clipboard detection`() = test { + usdtEnabled.value = false + runCurrent() + for (input in listOf("bitcoin:bcrt1qtest", "lnbcrt1test")) { + whenever(coreService.decode(input)).thenReturn(mock()) + assertTrue(sut.canDecodeClipboard(input)) + sut.mainScreenEffect.test { + sut.onClipboardAutoRead(input) + assertEquals(MainScreenEffect.ProcessClipboardAutoRead(input), awaitItem()) + } + } + } + + @Test + fun `foreground polling reconciles configured USDT payment proofs`() = test { + whenever(usdtRepo.hasStoredWallet()).thenReturn(true) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + clearInvocations(paykitUsdtPayments) + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + if (Env.isUsdtEnabled) { + verify(paykitUsdtPayments, atLeastOnce()).reconcile() + } else { + verify(paykitUsdtPayments, never()).reconcile() + } + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `current toast is hidden only when dev mode and disable all toasts are both on`() = test { val toast = Toast(Toast.ToastType.SUCCESS, "Toast", null, true, Toast.VISIBILITY_TIME_DEFAULT, null) @@ -389,6 +582,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Suppress("LongMethod") private fun stubRepositories() { + whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) whenever(context.getString(any())).thenReturn("") whenever(context.getSystemService(Context.CLIPBOARD_SERVICE)).thenReturn(clipboardManager) whenever(connectivityRepo.isOnline).thenReturn(connectivityState) @@ -446,6 +640,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(pubkyRepo.contactsLoadCompletionVersion).thenReturn(pubkyContactsLoadCompletionVersion) + whenever(usdtRepo.state).thenReturn(usdtState) + whenever { usdtRepo.refresh(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { paykitUsdtPayments.reconcile() }.thenReturn(Result.success(Unit)) + whenever(paykitUsdtPayments.receipts).thenReturn(MutableStateFlow(emptyList())) + whenever { paykitUsdtPayments.send(any(), anyOrNull(), any()) }.doSuspendableAnswer { + val beforeSend = it.getArgument Unit>(2) + runCatching { beforeSend() } + } whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever(paykitPaymentRequestRepo.paymentRequestHistory).thenReturn(paykitPaymentRequestHistory) whenever(paykitPaymentRequestRepo.subscriptions).thenReturn(paykitSubscriptions) @@ -485,7 +687,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { paykitPaymentProofRepo.captureOriginalOnchainPayer(any()) } .thenReturn(Result.success("pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg")) whenever { paykitPaymentProofRepo.verifyOriginalOnchainPayer(any(), any()) }.thenReturn(Result.success(Unit)) - whenever { paykitPaymentProofRepo.prepare(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever { + paykitPaymentProofRepo.prepare( + any(), + any(), + any(), + any(), + anyOrNull() + ) + }.thenReturn(Result.success(Unit)) whenever(paykitPaymentProofRepo.paymentRequestStateChanges(any())).thenReturn(proofStateVersion.drop(1).map { }) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) @@ -495,6 +705,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), any(), any(), + any(), anyOrNull(), anyOrNull(), anyOrNull(), @@ -505,7 +716,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>(), any()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any()) } + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.reconcileReservedReceiveIndexes() } .thenReturn(Result.success(Unit)) @@ -546,9 +757,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) } .thenReturn(Result.success(2u)) whenever(lightningRepo.canSend(any())).thenReturn(true) - whenever { lightningRepo.estimateRoutingFees(any()) }.thenReturn(Result.failure(Exception("No route"))) + whenever { lightningRepo.estimateRoutingFees(any()) }.thenReturn(Result.failure(AppError("No route"))) whenever { lightningRepo.estimateRoutingFeesForAmount(any(), any()) } - .thenReturn(Result.failure(Exception("No route"))) + .thenReturn(Result.failure(AppError("No route"))) whenever(toastManager.currentToast).thenReturn(toastState) } @@ -596,11 +807,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitLink = refreshContactPaykitLink, + usdtRepo = usdtRepo, + paykitUsdtPayments = paykitUsdtPayments, samRockRepo = samRockRepo, appUpdateSheet = mock(), backupSheet = mock(), notificationsSheet = mock(), quickPaySheet = mock(), + usdtSheet = mock(), + usdtPreferences = usdtPreferences, highBalanceSheet = mock(), formatMoneyValue = formatMoneyValue, widgetsRepo = widgetsRepo, @@ -1095,26 +1310,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() verify(paykitPaymentRequestRepo).refresh(any()) - verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any()) + verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true)) - repeat(5) { - clearInvocations(paykitPaymentRequestRepo) - advanceTimeBy(10.seconds.inWholeMilliseconds - 1) - runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh(any()) - advanceTimeBy(1) - runCurrent() - verify(paykitPaymentRequestRepo).refresh(any()) - } - verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any()) + repeat(5) { verifyPaymentRequestPollingInterval() } + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any(), any()) verify(paykitPaymentProofRepo).reconcile() verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() - verify(privatePaykitRepo, times(3)).refreshKnownSavedContactEndpoints(any(), any()) + verify(privatePaykitRepo, times(3)).refreshKnownSavedContactEndpoints(any(), any(), any()) verify(paykitPaymentProofRepo, times(3)).reconcile() } finally { sut.stopPaykitPaymentRequestPolling() @@ -1177,7 +1384,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) stubLightningScan(bolt11 = bolt11, amountSats = 0u) - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(true) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true @@ -1482,14 +1689,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val (request, signed) = reopenRetainedHardwareRequest(balanceSats = 0uL) assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) assertEquals(REGTEST_ADDRESS, sut.sendUiState.value.address) - assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertEquals(request.amount.atomic, sut.sendUiState.value.amount) assertTrue(sut.sendUiState.value.isAmountInputValid) assertFalse(sut.sendUiState.value.canSwitchFundingSource) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertEquals(signed.miningFeeSats.toLong(), sut.sendUiState.value.onchainFeeUi.sats) verify(privatePaykitRepo, never()).beginPaymentRequest(request) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) verify(hwWalletRepo, never()).signFunding(any(), any()) assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) sut.resetSendState(hardwareWalletId = null) @@ -1498,6 +1705,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(TransactionSpeed.Fast, sut.sendUiState.value.speed) } + @Test + fun `reopened hardware request preserves its original quote deadline`() = test { + val now = Clock.System.now() + val quote = recurringQuote(now, listOf(ConversionRate("usdt", "100000"))) + val (request, _) = reopenRetainedHardwareRequest(balanceSats = 0uL, quote = quote) + assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) + assertEquals(request.amount.atomic, sut.sendUiState.value.amount) + assertEquals(Instant.parse(quote.expiresAt), sut.hardwarePaymentDeadlineAt) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + } + @Test fun `reopened refused hardware request locks terms and reaches hardware sign on swipe`() = test { reopenRetainedHardwareRequest(balanceSats = 1_000_000uL, amountSats = 30_000uL) @@ -1529,7 +1747,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { cancelAndIgnoreRemainingEvents() } verify(privatePaykitRepo, never()).beginPaymentRequest(any()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) verify(hwWalletRepo, never()).signFunding(any(), any()) } @@ -1683,8 +1901,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { allowPreparation = false Result.success(PublicPaykitPaymentResult.Opened(bolt11, privatePaymentContext(7uL))) } - stubLightningScan(bolt11, request.amountSats) - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(false) + stubLightningScan(bolt11, request.amount.atomic) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(false) whenever(formatMoneyValue(any())).thenReturn("2,500 sats") pendingPaykitPaymentRequests.value = listOf(request) runCurrent() @@ -1700,7 +1918,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(true) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) allowPreparation = true sut.openIncomingPaymentRequest(request.id) @@ -2507,7 +2725,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { scanStarted.complete(Unit) awaitCancellation() } - stubLightningScan(bolt11 = queuedBolt11, amountSats = queuedRequest.amountSats) + stubLightningScan(bolt11 = queuedBolt11, amountSats = queuedRequest.amount.atomic) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) pendingPaykitPaymentRequests.value = listOf(expiredRequest, queuedRequest) enablePaykitUi() @@ -2552,7 +2770,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { "The payment request has expired." ) stubOpenedPaymentRequest(request, bolt11) - stubLightningScan(bolt11 = bolt11, amountSats = request.amountSats) + stubLightningScan(bolt11 = bolt11, amountSats = request.amount.atomic) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() @@ -3000,7 +3218,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { holdRequestScan.await() Scanner.Lightning(lightningInvoice(requestInvoice, 0u)) } - stubLightningScan(bolt11 = replacementInvoice, amountSats = request.amountSats) + stubLightningScan(bolt11 = replacementInvoice, amountSats = request.amount.atomic) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() @@ -3045,7 +3263,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } Scanner.Lightning(lightningInvoice(bolt11, 0u)) } - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(true) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) sut.onScanResult(bolt11) @@ -3076,7 +3294,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishScan.await() Scanner.Lightning(lightningInvoice(bolt11, 0u)) } - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(true) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) sut.openContactPayment( @@ -3900,6 +4118,150 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.canSwitchWallet) } + @Test + fun `USDT URI scans preserve the amount and bypass Bitcoin decoding`() = test { + val recipient = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" + val uri = "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=$recipient" + Mockito.mockStatic(Class.forName("to.bitkit.repositories.UsdtRepoKt")).use { parser -> + val cases = listOf(Triple("", null, ""), Triple("&uint256=10001", 10001uL, "0.010001")) + for ((suffix, amount, display) in cases) { + parser.`when`> { + parseUsdtPaymentRequest(uri + suffix, UsdtDestination.ARBITRUM) + } + .thenReturn(Result.success(UsdtPaymentRequest(recipient, amount, 42161u))) + sut.mainScreenEffect.test { + sut.onScanResult(uri + suffix) + advanceUntilIdle() + if (Env.isUsdtEnabled) { + assertEquals(MainScreenEffect.Navigate(Routes.UsdtPayment(recipient, display)), awaitItem()) + } + expectNoEvents() + } + } + } + verify(coreService, never()).decode(any()) + verify(usdtRepo, never()).quote(any(), any(), any(), any()) + } + + @Test + fun `manual USDT URI validation preserves routing and hardware restrictions`() = test { + val recipient = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" + val uri = "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=$recipient&uint256=10001" + Mockito.mockStatic(Class.forName("to.bitkit.repositories.UsdtRepoKt")).use { parser -> + parser.`when`> { parseUsdtPaymentRequest(uri, UsdtDestination.ARBITRUM) } + .thenReturn(Result.success(UsdtPaymentRequest(recipient, 10001u, 42161u))) + sut.showSheet(Sheet.Send()) + sut.setSendEvent(SendEvent.AddressChange(uri)) + advanceUntilIdle() + assertEquals(Env.isUsdtEnabled, sut.sendUiState.value.isAddressInputValid) + sut.mainScreenEffect.test { + sut.setSendEvent(SendEvent.AddressContinue(uri)) + advanceUntilIdle() + if (Env.isUsdtEnabled) { + assertEquals(MainScreenEffect.Navigate(Routes.UsdtPayment(recipient, "0.010001")), awaitItem()) + } + expectNoEvents() + } + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + sut.setSendEvent(SendEvent.AddressChange(uri)) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.isAddressInputValid) + + sut.showSheet(Sheet.Send()) + parser.`when`> { parseUsdtPaymentRequest(uri, UsdtDestination.ARBITRUM) } + .thenReturn(Result.failure(UsdtException.WrongNetwork())) + sut.setSendEvent(SendEvent.AddressChange(uri)) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.isAddressInputValid) + } + verify(coreService, never()).decode(any()) + } + + @Test + fun `manual own USDT address explains why sending is blocked`() = test { + if (!Env.isUsdtEnabled) return@test + val address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" + usdtState.value = usdtState.value.copy(address = address) + sut.showSheet(Sheet.Send()) + sut.setSendEvent(SendEvent.AddressChange(address.lowercase())) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.isAddressInputValid) + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("SelfPaymentToast", toastCaptor.lastValue.testTag) + assertEquals(context.getString(R.string.usdt__error_self_send), toastCaptor.lastValue.description) + verify(coreService, never()).decode(any()) + verify(usdtRepo, never()).quote(any(), any(), any(), any()) + } + + @Test + fun `invalid USDT links and active USDT sends cannot open another payment`() = test { + val uri = "ethereum:invalid" + Mockito.mockStatic(Class.forName("to.bitkit.repositories.UsdtRepoKt")).use { parser -> + parser.`when`> { parseUsdtPaymentRequest(uri, UsdtDestination.ARBITRUM) } + .thenReturn(Result.failure(UsdtException.WrongNetwork())) + sut.mainScreenEffect.test { + sut.onScanResult(uri) + advanceUntilIdle() + expectNoEvents() + parser.`when`> { parseUsdtPaymentRequest(uri, UsdtDestination.ARBITRUM) } + .thenReturn(Result.success(UsdtPaymentRequest("owned recipient", 10001u, 42161u))) + whenever(usdtRepo.isSendPresented).thenReturn(true) + clearInvocations(toastManager) + for (input in listOf(uri, "0x2222222222222222222222222222222222222222")) { + assertFalse(sut.canDecodeClipboard(input)) + sut.onClipboardAutoRead(input) + advanceUntilIdle() + expectNoEvents() + setActiveContactPaymentContext(testPublicKey) + sut.onScanResult(input) + advanceUntilIdle() + expectNoEvents() + assertEquals(testPublicKey, activeContactPaymentContext()?.publicKey) + } + verify(toastManager, never()).enqueue(any()) + } + } + verify(coreService, never()).decode(any()) + verify(usdtRepo, never()).quote(any(), any(), any(), any()) + } + + @Test + fun `USDT payment inputs preserve an active contact draft`() = test { + val recipient = "0x1111111111111111111111111111111111111111" + val endpoint = Endpoint(MethodId.UsdtArbitrum, recipient, rawPayload = "") + sut.openContactPayment(recipient, testPublicKey, endpoints = listOf(endpoint)) + advanceUntilIdle() + sut.onUsdtAmountChange("0.05") + advanceUntilIdle() + val draft = sut.sendUiState.value + val contact = activeContactPaymentContext() + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + + clearInvocations(toastManager) + val otherRecipient = "0x2222222222222222222222222222222222222222" + val inputs = listOf( + "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=$otherRecipient", + " $otherRecipient ", + ) + sut.mainScreenEffect.test { + for (isReviewing in listOf(false, true)) { + whenever(usdtRepo.isSendPresented).thenReturn(isReviewing) + for (input in inputs) { + assertFalse(sut.canDecodeClipboard(input)) + sut.onClipboardAutoRead(input) + sut.onScanResult(input) + advanceUntilIdle() + expectNoEvents() + assertEquals(draft, sut.sendUiState.value) + assertEquals(contact, activeContactPaymentContext()) + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + } + } + } + verify(toastManager, never()).enqueue(any()) + } + @Test fun `manual address continue routes pubky to add contact`() = test { enablePaykitUi() @@ -4753,7 +5115,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1afterrejectedcontact" balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(true) stubOpenedPaymentRequest(request, bolt11) whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) @@ -4763,7 +5125,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) - assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertEquals(request.amount.atomic, sut.sendUiState.value.amount) } @Test @@ -7520,6 +7882,141 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `mixed contact endpoints retain the fixed invoice amount on spending`() = test { + val bolt11 = "lnbcrt1contactfixed" + val recipient = "0x1111111111111111111111111111111111111111" + stubLightningScan(bolt11, amountSats = 10_000u) + whenever(coreService.decode(REGTEST_ADDRESS)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 0u, null, null, null)), + ) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + endpoints = listOf( + Endpoint(MethodId.Bolt11, bolt11, rawPayload = ""), + Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = ""), + Endpoint(MethodId.UsdtArbitrum, recipient, rawPayload = ""), + ), + ) + advanceUntilIdle() + + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + assertFalse(sut.sendUiState.value.isAmountEditable) + assertEquals(10_000uL, sut.sendUiState.value.amount) + sut.setSendEvent(SendEvent.AmountChange(1_000u)) + advanceUntilIdle() + assertEquals(10_000uL, sut.sendUiState.value.amount) + + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertTrue(sut.sendUiState.value.isAmountEditable) + sut.setSendEvent(SendEvent.AmountChange(1_000u)) + advanceUntilIdle() + assertEquals(1_000uL, sut.sendUiState.value.amount) + + sut.switchToLightning() + advanceUntilIdle() + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(10_000uL, sut.sendUiState.value.amount) + assertFalse(sut.sendUiState.value.isAmountEditable) + } + + @Test + fun `confirmation source cycling stays on bitcoin even when the contact accepts USDT`() = test { + val bolt11 = "lnbcrt1contactfixed" + stubLightningScan(bolt11, amountSats = 10_000u) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + decodedInvoice = lightningInvoice(bolt11, amountSats = 10_000u), + usdtRecipient = "0x1111111111111111111111111111111111111111", + amount = 10_000u, + payMethod = SendMethod.ONCHAIN, + ), + ) + repeat(4) { + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + assertEquals(if (it % 2 == 0) SendMethod.LIGHTNING else SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + } + } + + @Test + fun `hardware mixed contact uses only its onchain endpoint`() = test { + val bolt11 = "lnbcrt1contactfixed" + stubLightningScan(bolt11, amountSats = 10_000u) + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).thenReturn( + ValidationResult(REGTEST_ADDRESS, NetworkType.REGTEST, AddressType.P2WPKH), + ) + whenever(hwWalletRepo.maxSpendableFunding(any(), any(), any())).thenReturn(Result.success(48_000uL)) + whenever(coreService.decode(REGTEST_ADDRESS)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 0u, null, null, null)), + ) + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + endpoints = listOf( + Endpoint(MethodId.Bolt11, bolt11, rawPayload = ""), + Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = ""), + Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = ""), + ), + ) + advanceUntilIdle() + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + assertNull(sut.sendUiState.value.decodedInvoice) + assertNull(sut.sendUiState.value.usdtRecipient) + } + + @Test + fun `mixed contact with an amountless invoice permits spending amount entry`() = test { + val bolt11 = "lnbcrt1contactamountless" + stubLightningScan(bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + endpoints = listOf( + Endpoint(MethodId.Bolt11, bolt11, rawPayload = ""), + Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = ""), + ), + ) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + assertTrue(sut.sendUiState.value.isAmountEditable) + + sut.setSendEvent(SendEvent.AmountChange(1_000u)) + advanceUntilIdle() + assertEquals(1_000uL, sut.sendUiState.value.amount) + } + + @Test + fun `lightning send rejects an invoice amount different from the reviewed amount`() = test { + val bolt11 = "lnbcrt1contactfixed" + setActiveContactPaymentContext(testPublicKey) + setSendState( + SendUiState( + amount = 1_000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 10_000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), anyOrNull(), any()) + verify(paykitPaymentProofRepo, never()).associateLightningPayment(any(), any(), any(), any()) + } + @Test fun `incoming payment request opens the existing confirm flow with its fixed amount`() = test { val request = paymentRequest() @@ -7534,8 +8031,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) - whenever(lightningRepo.estimateRoutingFeesForAmount(bolt11, request.amountSats)) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(true) + whenever(lightningRepo.estimateRoutingFeesForAmount(bolt11, request.amount.atomic)) .thenReturn(Result.success(4uL)) val privateContext = stubOpenedPaymentRequest(request, bolt11) whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) @@ -7547,7 +8044,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.stopPaykitPaymentRequestPolling() assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) - assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertEquals(request.amount.atomic, sut.sendUiState.value.amount) assertEquals(4L, sut.sendUiState.value.lightningFeeSats) assertTrue(sut.sendUiState.value.isAmountInputValid) assertTrue(sut.sendUiState.value.isPaymentRequest) @@ -7607,7 +8104,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(coreService).decode(REGTEST_ADDRESS) - assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertEquals(request.amount.atomic, sut.sendUiState.value.amount) assertTrue(sut.sendUiState.value.isAmountInputValid) assertTrue(sut.sendUiState.value.isPaymentRequest) assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) @@ -7628,7 +8125,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() for (amount in listOf(0uL, 1uL, 546uL)) { - val request = paymentRequest().copy(amountSats = amount) + val request = paymentRequest().copy(amount = PaykitAmount(PaykitAsset.BTC, amount)) stubIncomingOnchainRequest(request) sut.openContactPayment( paymentRequest = REGTEST_ADDRESS, @@ -7655,7 +8152,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `below minimum request closes preparation and does not block another request`() = test { - val request = paymentRequest().copy(amountSats = 100uL) + val request = paymentRequest().copy(amount = PaykitAmount(PaykitAsset.BTC, 100uL)) stubIncomingOnchainRequest(request) whenever(context.getString(R.string.wallet__lnurl_pay__error_min__title)).thenReturn("Amount Too Low") whenever(context.getString(R.string.wallet__lnurl_pay__error_min__description)).thenReturn("Minimum: {amount}") @@ -7686,7 +8183,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) - val payable = paymentRequest().copy(paymentRequestId = "payable-request", amountSats = 547uL) + val payable = paymentRequest().copy( + paymentRequestId = "payable-request", + amount = PaykitAmount(PaykitAsset.BTC, 547uL), + ) stubIncomingOnchainRequest(payable) pendingPaykitPaymentRequests.value = listOf(request, payable) runCurrent() @@ -7697,7 +8197,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `incoming onchain request at minimum remains payable`() = test { - val request = paymentRequest().copy(amountSats = 547uL) + val request = paymentRequest().copy(amount = PaykitAmount(PaykitAsset.BTC, 547uL)) stubIncomingOnchainRequest(request) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) sut.setIsAuthenticated(true) @@ -7747,11 +8247,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `small incoming unified request remains payable over Lightning`() = test { - val request = paymentRequest().copy(amountSats = 100uL) + val request = paymentRequest().copy(amount = PaykitAmount(PaykitAsset.BTC, 100uL)) val uri = "bitcoin:$REGTEST_ADDRESS?lightning=lnbcrt1smallrequest" - stubUnifiedScan(uri, "lnbcrt1smallrequest", request.amountSats, FOREIGN_NODE_ID.fromHex()) + stubUnifiedScan(uri, "lnbcrt1smallrequest", request.amount.atomic, FOREIGN_NODE_ID.fromHex()) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) - whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.canSend(request.amount.atomic)).thenReturn(true) whenever(lightningRepo.estimateRoutingFees("lnbcrt1smallrequest")).thenReturn(Result.success(1uL)) sut.setIsAuthenticated(true) runCurrent() @@ -7773,7 +8273,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest().copy(counterparty = "pubkyrecipient") val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( - amountSats = request.amountSats, + amount = PaykitAmount(PaykitAsset.BTC, request.amount.atomic), note = "Lunch", expiresAt = Clock.System.now() + 60.seconds, ) @@ -7801,7 +8301,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest().copy(counterparty = "pubkyrecipient") val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( - amountSats = request.amountSats, + amount = PaykitAmount(PaykitAsset.BTC, request.amount.atomic), note = "Lunch", expiresAt = Clock.System.now() + 60.seconds, ) @@ -7830,7 +8330,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest().copy(counterparty = "pubkyrecipient") val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( - amountSats = request.amountSats, + amount = PaykitAmount(PaykitAsset.BTC, request.amount.atomic), note = "Lunch", expiresAt = Clock.System.now() + 60.seconds, ) @@ -7902,12 +8402,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - stubSuccessfulOnchainSend(address, request.amountSats) + stubSuccessfulOnchainSend(address, request.amount.atomic) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8020,12 +8520,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - stubSuccessfulOnchainSend(address, request.amountSats) + stubSuccessfulOnchainSend(address, request.amount.atomic) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8058,12 +8558,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - stubSuccessfulOnchainSend(address, request.amountSats) + stubSuccessfulOnchainSend(address, request.amount.atomic) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8091,12 +8591,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( any(), any(), any(), + any(), anyOrNull(), anyOrNull(), anyOrNull(), @@ -8127,14 +8628,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1quncertainwithoutproof", - sats = request.amountSats, + sats = request.amount.atomic, result = Result.failure(IllegalStateException("outcome unknown")), ) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = "bcrt1quncertainwithoutproof", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8174,7 +8675,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) .doSuspendableAnswer { - setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) + setSendState( + sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amount.atomic)) + ) Result.success(Unit) } whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) } @@ -8187,7 +8690,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = bolt11, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, isPaymentRequest = true, ), @@ -8227,7 +8730,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { PaykitPaymentProofKind.Lightning ), ).doSuspendableAnswer { - setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) + setSendState( + sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amount.atomic)) + ) if (preparationSucceeds) { Result.success(Unit) } else { @@ -8254,7 +8759,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = bolt11, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, isPaymentRequest = true, ), @@ -8292,8 +8797,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) .doSuspendableAnswer { - setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) - Result.success(Unit) + setSendState( + sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amount.atomic)) + ) + Result.success(Unit) } whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -8304,7 +8811,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = bolt11, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, isPaymentRequest = true, ), @@ -8349,7 +8856,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) .doSuspendableAnswer { - setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) + setSendState( + sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amount.atomic)) + ) Result.success(Unit) } whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8362,7 +8871,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = bolt11, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, isPaymentRequest = true, ), @@ -8412,7 +8921,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(lightningRepo.fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null)) + whenever(lightningRepo.fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amount.atomic), null)) .thenReturn(Result.failure(IllegalStateException("callback failed"))) sut.showSheet(Sheet.Send(SendRoute.Confirm)) advanceUntilIdle() @@ -8420,7 +8929,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = lnurl.uri, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, lnurl = LnurlParams.LnurlPay(lnurl), isPaymentRequest = true, @@ -8439,7 +8948,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) inOrder(paykitPaymentRequestRepo, lightningRepo).apply { verify(paykitPaymentRequestRepo).accept(request) - verify(lightningRepo).fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null) + verify(lightningRepo).fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amount.atomic), null) } verify(toastManager, never()).enqueue(any()) @@ -8477,7 +8986,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) .doSuspendableAnswer { - setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) + setSendState( + sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amount.atomic)) + ) Result.success(Unit) } whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8489,7 +9000,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = bolt11, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, isPaymentRequest = true, ), @@ -8499,7 +9010,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - assertEquals(SendEffect.NavigateToPending(paymentHash, request.amountSats.toLong()), awaitItem()) + assertEquals(SendEffect.NavigateToPending(paymentHash, request.amount.atomic.toLong()), awaitItem()) } verify(pendingPaymentRepo).track(paymentHash) verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) @@ -8526,7 +9037,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = bolt11, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, isPaymentRequest = true, ), @@ -8555,7 +9066,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( - SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, isPaymentRequest = true), + SendUiState(address = "bcrt1qpaymentrequest", amount = request.amount.atomic, isPaymentRequest = true), ) assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) @@ -8566,6 +9077,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), any(), any(), + any(), anyOrNull(), anyOrNull(), anyOrNull(), @@ -8585,7 +9097,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8600,6 +9112,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).markOnchainPaymentStarted( request, "bcrt1qpaymentrequest", + request.amount.atomic, "hardware-wallet", 7uL, hardwareSignedReceipt(), @@ -8624,6 +9137,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentProofRepo.markOnchainPaymentStarted( request, "bcrt1qpaymentrequest", + request.amount.atomic, "hardware-wallet", 7uL, hardwareSignedReceipt(), @@ -8636,7 +9150,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8662,7 +9176,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8681,6 +9195,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).markOnchainPaymentStarted( request, "bcrt1qpaymentrequest", + request.amount.atomic, "hardware-wallet", 7uL, hardwareSignedReceipt(), @@ -8734,7 +9249,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, isPaymentRequest = true, hardwareWalletId = "hardware-wallet", @@ -8813,7 +9328,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8895,7 +9410,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8924,7 +9439,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -8958,8 +9473,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)) .thenReturn(false) setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) - setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, - payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amount.atomic, + payMethod = SendMethod.ONCHAIN, + hardwareWalletId = walletId, + isPaymentRequest = true + ) + ) sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) advanceUntilIdle() assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) @@ -8985,14 +9507,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted( request, "bcrt1qpaymentrequest", + request.amount.atomic, walletId, 7uL, hardwareSignedReceipt(), ) - verify( - lightningRepo, - never() - ).sendOnChain( + verify(lightningRepo, never()).sendOnChain( any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), paymentDeadlineAt = anyOrNull(), @@ -9016,7 +9536,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -9031,6 +9551,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), any(), any(), + any(), anyOrNull(), anyOrNull(), anyOrNull(), @@ -9059,7 +9580,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpaymentrequest", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -9093,13 +9614,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val walletId = "original-hardware-wallet" whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) - setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, - payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amount.atomic, + payMethod = SendMethod.ONCHAIN, + hardwareWalletId = walletId, + isPaymentRequest = true + ) + ) assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) pubkyPublicKey.value = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request.copy(paymentRequestId = "another-request")) - setSendState(SendUiState(address = "bcrt1qother", amount = 9_000uL, - payMethod = SendMethod.ONCHAIN, hardwareWalletId = "another-wallet", isPaymentRequest = true)) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request.copy(paymentRequestId = "another-request") + ) + setSendState( + SendUiState( + address = "bcrt1qother", + amount = 9_000uL, + payMethod = SendMethod.ONCHAIN, + hardwareWalletId = "another-wallet", + isPaymentRequest = true + ) + ) whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)) .thenReturn(true) @@ -9120,7 +9658,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `retained resolution replays when its original payer becomes active`() = test { val request = paymentRequest() val resolution = PaykitOnchainPaymentProofResolution( - testPublicKey, request.id, "ab".repeat(32), "hardware-original", request.amountSats, + testPublicKey, + request.id, + "ab".repeat(32), + "hardware-original", + request.amount.atomic, ) onchainPaymentResolutions.value = listOf(resolution) runCurrent() @@ -9139,7 +9681,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() val request = paymentRequest() val first = PaykitOnchainPaymentProofResolution( - testPublicKey, request.id, "ab".repeat(32), "hardware-first", request.amountSats, + testPublicKey, + request.id, + "ab".repeat(32), + "hardware-first", + request.amount.atomic, ) val second = first.copy(transactionId = "cd".repeat(32), walletId = "hardware-second") onchainPaymentResolutions.value = listOf(first, second) @@ -9165,19 +9711,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val walletId = "original-hardware-wallet" pubkyPublicKey.value = testPublicKey setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) - setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = 9_000uL, - payMethod = SendMethod.ONCHAIN, hardwareWalletId = "different-selected-wallet", - incomingPaymentRequestId = request.id, isPaymentRequest = true)) - sut.showSheet(Sheet.Send(SendRoute.Pending(txid, request.amountSats.toLong(), false, isOnchain = true))) - whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)).thenReturn(false) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = 9_000uL, + payMethod = SendMethod.ONCHAIN, + hardwareWalletId = "different-selected-wallet", + incomingPaymentRequestId = request.id, + isPaymentRequest = true + ) + ) + sut.showSheet(Sheet.Send(SendRoute.Pending(txid, request.amount.atomic.toLong(), false, isOnchain = true))) + whenever( + paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey) + ).thenReturn(false) assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) - onchainPaymentResolutions.value = listOf(PaykitOnchainPaymentProofResolution( - testPublicKey, request.id, txid, walletId, request.amountSats, - )) + onchainPaymentResolutions.value = listOf( + PaykitOnchainPaymentProofResolution( + testPublicKey, request.id, txid, walletId, request.amount.atomic, + ) + ) runCurrent() assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) assertEquals(walletId, sut.successSendUiState.value.activityWalletId) - assertEquals(request.amountSats.toLong(), sut.successSendUiState.value.sats) + assertEquals(request.amount.atomic.toLong(), sut.successSendUiState.value.sats) verify( lightningRepo, never() @@ -9293,12 +9850,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - stubSuccessfulOnchainSend(address, request.amountSats) + stubSuccessfulOnchainSend(address, request.amount.atomic) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -9344,6 +9901,62 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.shouldConfirmPay) } + @Test + fun `quoted initial subscription payment waits for amount review`() = test { + setSendState( + SendUiState( + amount = 1_000u, + payMethod = SendMethod.LIGHTNING, + isAmountInputValid = true, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + requiresAmountReview = true, + ) + ) + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.shouldAutomaticallyPay) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + + @Test + fun `switching request payment methods updates the quoted amount`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + val request = paymentRequest().copy( + pricing = PaykitRequestPricing( + PaymentConversion.Fixed( + listOf( + ConversionRate("btc", "2"), + ConversionRate("btc-lightning", "0.5") + ) + ) + ) + ) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setUnifiedState(amount = request.amount.atomic / 2u, payMethod = SendMethod.LIGHTNING) + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertEquals(SendEffect.NavigateToAmount, awaitItem()) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertEquals(request.amount.atomic * 2u, sut.sendUiState.value.amount) + + sut.switchToLightning() + advanceUntilIdle() + assertEquals(SendEffect.NavigateToAmount, awaitItem()) + sut.refreshPaykitRequestAmount() + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(request.amount.atomic / 2u, sut.sendUiState.value.amount) + + sut.setTransactionSpeed(TransactionSpeed.Medium) + advanceUntilIdle() + assertEquals(SendEffect.NavigateToAmount, awaitItem()) + sut.refreshPaykitRequestAmount() + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertEquals(request.amount.atomic * 2u, sut.sendUiState.value.amount) + } + } + @Test fun `initial onchain subscription payment starts automatically`() = test { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) @@ -9581,8 +10194,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `subscription acceptance pays a due period materialized during acceptance`() = test { val subscription = subscriptionStartingAt(Clock.System.now() + 60.seconds) + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") val dueRequest = paymentRequest().copy( paymentRequestId = subscription.paymentRequestId, + amount = PaykitAmount(PaykitAsset.USDT, 50_000uL), + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, billingPeriod = PaykitBillingPeriod( startsAt = Clock.System.now(), @@ -9593,7 +10209,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(subscription.paymentDueOnAcceptance(Clock.System.now())) whenever(paykitPaymentRequestRepo.accept(subscription)).thenReturn(Result.success(dueRequest)) whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(dueRequest)).thenReturn( - Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + Result.success(PublicPaykitPaymentResult.Opened(endpoint.value, endpoints = listOf(endpoint))) ) sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) advanceUntilIdle() @@ -9602,12 +10218,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(awaitItem() is Sheet.Subscription) assertTrue(sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow()) advanceUntilIdle() - assertTrue((awaitItem() as Sheet.Send).route is SendRoute.Error) + assertEquals(Sheet.Send(SendRoute.Amount), awaitItem()) expectNoEvents() } verify(privatePaykitRepo).beginPaymentRequestWaitingForUpdatedList(dueRequest) assertTrue(sut.currentSheet.value is Sheet.Send) assertTrue(sut.sendUiState.value.isInitialSubscriptionPayment) + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + assertEquals(endpoint.value, sut.sendUiState.value.usdtRecipient) + assertEquals("0.05", sut.sendUiState.value.paykitAmount) assertEquals(dueRequest.id, sut.sendUiState.value.incomingPaymentRequestId) assertFalse(sut.isAcceptingSubscription.value) } @@ -9682,7 +10301,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `initial subscription retry keeps the send sheet presented`() = test { - val request = paymentRequest() + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USDT, 50_000uL), + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), + ) enablePaykitUi() pubkyPublicKey.value = testPublicKey runCurrent() @@ -9693,7 +10316,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success(Unit) } whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( - Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + Result.success(PublicPaykitPaymentResult.Opened(endpoint.value, endpoints = listOf(endpoint))) ) val sendSheet = Sheet.Send( SendRoute.errorFromFailure( @@ -9723,6 +10346,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).beginPaymentRequestWaitingForUpdatedList(request) assertTrue(sut.currentSheet.value is Sheet.Send) assertFalse(sut.isRetryingInitialSubscriptionPayment.value) + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + assertEquals(endpoint.value, sut.sendUiState.value.usdtRecipient) + assertEquals("0.05", sut.sendUiState.value.paykitAmount) } @Test @@ -9782,7 +10408,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1qpreflightfailure", - sats = request.amountSats, + sats = request.amount.atomic, result = Result.failure(OnchainSendNotDispatchedError(IllegalStateException("preflight failed"))), invokeBeforeSendAttempt = false, ) @@ -9790,7 +10416,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpreflightfailure", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -9818,13 +10444,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) whenever( - paykitPaymentProofRepo.markOnchainPaymentStarted(request, address, WalletScope.default, 7uL), + paykitPaymentProofRepo.markOnchainPaymentStarted( + request, + address, + request.amount.atomic, + WalletScope.default, + 7uL + ), ).doSuspendableAnswer { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) Result.success(Unit) } - stubSuccessfulOnchainSend(address, request.amountSats) + stubSuccessfulOnchainSend(address, request.amount.atomic) setActiveContactPaymentContext( testPublicKey, privateContext, @@ -9834,7 +10466,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -9850,7 +10482,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any(), eq("bitkit"), anyOrNull()) + verify( + paykitPaymentProofRepo, + never() + ).completeOnchainPayment(any(), any(), any(), eq("bitkit"), anyOrNull(), anyOrNull()) } @Test @@ -9871,7 +10506,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1qdefinitefailure", - sats = request.amountSats, + sats = request.amount.atomic, result = Result.failure(OnchainSendNotDispatchedError(error)), ) setActiveContactPaymentContext( @@ -9883,7 +10518,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qdefinitefailure", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -9913,14 +10548,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1quncertainfailure", - sats = request.amountSats, + sats = request.amount.atomic, result = Result.failure(IllegalStateException("outcome unknown")), ) setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) setSendState( SendUiState( address = "bcrt1quncertainfailure", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -9934,7 +10569,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val pendingRoute = SendRoute.Pending( request.paymentRequestId, - request.amountSats.toLong(), + request.amount.atomic.toLong(), observeResolution = false, isOnchain = true, ) @@ -9999,7 +10634,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1quncertainreplacement", - sats = request.amountSats, + sats = request.amount.atomic, result = Result.failure(IllegalStateException("outcome unknown")), ) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -10008,7 +10643,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1quncertainreplacement", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -10023,7 +10658,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qreplacement", - amount = replacementRequest.amountSats, + amount = replacementRequest.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -10056,7 +10691,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1qbookkeepingfailure", - sats = request.amountSats, + sats = request.amount.atomic, result = Result.success(OnchainSendOutcome.Accepted("broadcast-txid")), broadcastTxId = "broadcast-txid", ) @@ -10064,7 +10699,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qbookkeepingfailure", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -10090,7 +10725,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -10135,7 +10770,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -10176,7 +10811,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = bolt11, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.LIGHTNING, decodedInvoice = lightningInvoice(bolt11, amountSats = 2_501uL), isPaymentRequest = true, @@ -10257,8 +10892,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `payment deadline crossed during LNURL callback prevents Lightning submission`() = test { - val request = paymentRequest().copy(paymentDeadlineAt = Instant.parse("2026-10-06T12:00:00Z")) + fun `quoted payment carries its earlier deadline through LNURL and hardware submission`() = test { + val now = Clock.System.now() + val period = PaykitBillingPeriod(now - 60.seconds, now + 3_600.seconds) + val quote = recurringQuote(now, listOf(ConversionRate("btc", "0.00002"))) + val request = paymentRequest().copy( + amount = PaykitAmount.parse(PaykitAsset.USD, "1.25"), + pricing = PaykitRequestPricing(PaymentConversion.PerPeriod, quotes = listOf(quote)), + billingPeriod = period, + paymentDeadlineAt = now + 120.seconds, + ) + val amountSats = request.payment(MethodId.Bolt11, now).amount.atomic val privateContext = privatePaymentContext(7uL) val lnurl = LnurlPayData( uri = "lnurl1deadline", @@ -10275,23 +10919,31 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(lightningRepo.fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null)) + whenever(lightningRepo.fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(amountSats), null)) .doSuspendableAnswer { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) - Result.success(lightningInvoice("lnbcrt1deadline", request.amountSats)) + Result.success(lightningInvoice("lnbcrt1deadline", amountSats)) } - setActiveContactPaymentContext(testPublicKey, privateContext, request) + setActiveContactPaymentContext( + testPublicKey, + privateContext, + request, + endpoints = listOf(Endpoint(MethodId.Lnurl, lnurl.uri, rawPayload = "")), + ) setSendState( SendUiState( address = lnurl.uri, - amount = request.amountSats, + amount = amountSats, payMethod = SendMethod.LIGHTNING, lnurl = LnurlParams.LnurlPay(lnurl), isPaymentRequest = true, ), ) + sut.setSendEvent(SendEvent.AmountContinue) + advanceUntilIdle() + assertEquals(Instant.parse(quote.expiresAt), sut.hardwarePaymentDeadlineAt) sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() @@ -10299,7 +10951,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).cancelPreparation(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - verify(lightningRepo).payInvoice(any(), anyOrNull(), eq(request.paymentDeadlineAt), any()) + verify(lightningRepo).payInvoice(any(), anyOrNull(), eq(Instant.parse(quote.expiresAt)), any()) } @Test @@ -10312,7 +10964,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = address, - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, isPaymentRequest = true, @@ -10404,7 +11056,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) stubOnchainSend( "bcrt1qpreadmission", - request.amountSats, + request.amount.atomic, Result.failure(IllegalArgumentException("fee estimation failed")), invokeBeforeSendAttempt = false ) @@ -10412,7 +11064,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qpreadmission", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, isPaymentRequest = true, isInitialSubscriptionPayment = true @@ -10446,7 +11098,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) stubOnchainSend( "bcrt1qblocked", - request.amountSats, + request.amount.atomic, Result.failure(error), invokeBeforeSendAttempt = sendAttempted, ) @@ -10454,7 +11106,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = "bcrt1qblocked", - amount = request.amountSats, + amount = request.amount.atomic, payMethod = SendMethod.ONCHAIN, isPaymentRequest = true, ), @@ -10541,12 +11193,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `blocked Shop recovery follows original request through success`() = test { val original = paymentRequest() - val incoming = original.copy(paymentRequestId = "new-request", amountSats = 3_000uL) + val incoming = original.copy(paymentRequestId = "new-request", amount = PaykitAmount(PaykitAsset.BTC, 3_000uL)) val privateContext = privatePaymentContext(7uL) val txid = "ef".repeat(32) val previous = OnchainSendAttempt( walletId = WalletScope.default, attemptId = "older-shop-payment", requestId = original.id, orderId = null, - address = "bcrt1qoriginal", amountSats = original.amountSats, isMaxAmount = false, + address = "bcrt1qoriginal", amountSats = original.amount.atomic, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Unknown, txid = txid, ) @@ -10557,22 +11209,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext, incoming, isInitialSubscriptionPayment = true) - setSendState(SendUiState(address = "bcrt1qnewshop", amount = incoming.amountSats, - payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, - isPaymentRequest = true, incomingPaymentRequestId = incoming.id)) - stubOnchainSend("bcrt1qnewshop", incoming.amountSats, Result.failure(OnchainSendBlockedError(previous))) - val resolution = PaykitOnchainPaymentProofResolution(testPublicKey, original.id, txid, - amountSats = original.amountSats) + setSendState( + SendUiState( + address = "bcrt1qnewshop", + amount = incoming.amount.atomic, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + incomingPaymentRequestId = incoming.id + ) + ) + stubOnchainSend("bcrt1qnewshop", incoming.amount.atomic, Result.failure(OnchainSendBlockedError(previous))) + val resolution = PaykitOnchainPaymentProofResolution( + testPublicKey, + original.id, + txid, + amountSats = original.amount.atomic + ) sut.sendEffect.test { confirmCurrentPayment() - assertEquals(SendEffect.NavigateToPending(txid, original.amountSats.toLong(), false, isOnchain = true), awaitItem()) + assertEquals( + SendEffect.NavigateToPending(txid, original.amount.atomic.toLong(), false, isOnchain = true), + awaitItem() + ) assertEquals(original.id, sut.sendUiState.value.incomingPaymentRequestId) - sut.showSheet(Sheet.Send(SendRoute.Pending(txid, original.amountSats.toLong(), false, isOnchain = true))) + sut.showSheet(Sheet.Send(SendRoute.Pending(txid, original.amount.atomic.toLong(), false, isOnchain = true))) onchainPaymentResolutions.value = listOf(resolution) assertEquals(SendEffect.PaymentSuccess, awaitItem()) assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) - assertEquals(original.amountSats.toLong(), sut.successSendUiState.value.sats) + assertEquals(original.amount.atomic.toLong(), sut.successSendUiState.value.sats) runCurrent() expectNoEvents() } @@ -10581,12 +11247,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `already resolved original Shop payment never returns to pending`() = test { val original = paymentRequest() - val incoming = original.copy(paymentRequestId = "new-request", amountSats = 3_000uL) + val incoming = original.copy(paymentRequestId = "new-request", amount = PaykitAmount(PaykitAsset.BTC, 3_000uL)) val privateContext = privatePaymentContext(7uL) val txid = "ef".repeat(32) val previous = OnchainSendAttempt( walletId = WalletScope.default, attemptId = "older-shop-payment", requestId = original.id, orderId = null, - address = "bcrt1qoriginal", amountSats = original.amountSats, isMaxAmount = false, + address = "bcrt1qoriginal", amountSats = original.amount.atomic, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Unknown, txid = txid, ) @@ -10597,12 +11263,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext, incoming, isInitialSubscriptionPayment = true) - setSendState(SendUiState(address = "bcrt1qnewshop", amount = incoming.amountSats, - payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, - isPaymentRequest = true, incomingPaymentRequestId = incoming.id)) - stubOnchainSend("bcrt1qnewshop", incoming.amountSats, Result.failure(OnchainSendBlockedError(previous))) - val resolution = PaykitOnchainPaymentProofResolution(testPublicKey, original.id, txid, - amountSats = original.amountSats) + setSendState( + SendUiState( + address = "bcrt1qnewshop", + amount = incoming.amount.atomic, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + incomingPaymentRequestId = incoming.id + ) + ) + stubOnchainSend("bcrt1qnewshop", incoming.amount.atomic, Result.failure(OnchainSendBlockedError(previous))) + val resolution = PaykitOnchainPaymentProofResolution( + testPublicKey, + original.id, + txid, + amountSats = original.amount.atomic + ) sut.showSheet(Sheet.Send(SendRoute.Confirm)) onchainPaymentResolutions.value = listOf(resolution) runCurrent() @@ -10610,7 +11287,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() assertEquals(SendEffect.PaymentSuccess, awaitItem()) assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) - assertEquals(original.amountSats.toLong(), sut.successSendUiState.value.sats) + assertEquals(original.amount.atomic.toLong(), sut.successSendUiState.value.sats) runCurrent() expectNoEvents() } @@ -11429,7 +12106,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { contactWork.await() Result.success(Unit) } - whenever(privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any())).doSuspendableAnswer { + whenever(privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any(), any())).doSuspendableAnswer { contactWork.await() Result.success(Unit) } @@ -11820,6 +12497,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) } + private suspend fun TestScope.verifyPaymentRequestPollingInterval() { + clearInvocations(paykitPaymentRequestRepo) + advanceTimeBy(10.seconds.inWholeMilliseconds - 1) + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + advanceTimeBy(1) + runCurrent() + verify(paykitPaymentRequestRepo).refresh(any()) + } + private fun enablePaykitUi() { isPaykitEnabled.value = true } @@ -11914,6 +12601,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { privatePaymentContext: PrivatePaykitPaymentContext? = null, incomingPaymentRequest: PaykitPaymentRequest? = null, isInitialSubscriptionPayment: Boolean = false, + endpoints: List = emptyList(), ) { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true @@ -11924,6 +12612,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { privatePaymentContext, incomingPaymentRequest, isInitialSubscriptionPayment, + endpoints = endpoints, ), ) } @@ -11990,6 +12679,273 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fundingBalanceSats = fundingBalanceSats, ) + @Test + fun `asset switching preserves the entered value when the exchange rate is unavailable`() = test { + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + usdtRecipient = "0x1111111111111111111111111111111111111111", + paykitUsesUsdt = true, + paykitAmount = "5", + isAmountInputValid = true, + ) + ) + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + runCurrent() + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + assertEquals("5", sut.sendUiState.value.paykitAmount) + assertTrue(sut.sendUiState.value.paykitRateUnavailable) + } + + @Test + fun `dollar requests prefer USDT only when the quoted amount is covered`() = test { + val bitcoin = Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = REGTEST_ADDRESS) + val usdt = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = "") + val endpoints = listOf(bitcoin, usdt) + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USD, 100u), + pricing = PaykitRequestPricing( + PaymentConversion.Fixed( + listOf( + ConversionRate("btc", "0.00001"), + ConversionRate("usdt", "1"), + ConversionRate("usdt-arbitrum", "1.2345678"), + ), + ) + ), + acceptedPaymentEndpointIdentifiers = endpoints.map { it.methodId.rawValue }, + ) + whenever(coreService.decode(REGTEST_ADDRESS)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 0u, null, null, null)), + ) + val cases = listOf( + 0uL to false, + 1_000_000uL to false, + 1_234_567uL to false, + 1_234_568uL to true, + 2_000_000uL to true, + ) + for ((balance, expected) in cases) { + usdtState.value = UsdtWalletState(balance = balance) + sut.openContactPayment( + REGTEST_ADDRESS, + testPublicKey, + incomingPaymentRequest = request, + endpoints = endpoints, + ) + advanceUntilIdle() + assertEquals(expected, sut.sendUiState.value.paykitUsesUsdt, "balance $balance") + assertEquals(if (expected) "1.234568" else "", sut.sendUiState.value.paykitAmount) + sut.resetSendState() + } + } + + @Test + fun `automatic USDT request finishes preparation and admits amount continue`() = test { + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USDT, 50_000u), + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.Opened(endpoint.value, endpoints = listOf(endpoint))) + ) + sut.setIsAuthenticated(true) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.sendEffect.test { + runCurrent() + assertEquals(SendEffect.NavigateToAmount, awaitItem()) + assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + sut.setSendEvent(SendEvent.AmountContinue) + assertEquals(SendEffect.NavigateToUsdtReview, awaitItem()) + } + } + + @Test + fun `converted request cannot enter review without a current exchange rate`() = test { + val endpoint = Endpoint( + MethodId.UsdtArbitrum, + "0x1111111111111111111111111111111111111111", + null, + null, + "" + ) + val request = paymentRequest() + val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") + field.isAccessible = true + field.set( + sut, + ContactPaymentContext(testPublicKey, incomingPaymentRequest = request, endpoints = listOf(endpoint)) + ) + setSendState(SendUiState(paykitUsesUsdt = true, paykitAmount = "5", isAmountInputValid = true)) + sut.setSendEvent(SendEvent.AmountContinue) + runCurrent() + assertTrue(sut.sendUiState.value.paykitRateUnavailable) + assertFalse(sut.sendUiState.value.isAmountInputValid) + verify(paykitUsdtPayments, never()).prepare(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `USDT contact submission pauses background work until preparation and sending finish`() = test { + setActiveContactPaymentContext(testPublicKey) + val preparing = CompletableDeferred() + val release = CompletableDeferred() + whenever(paykitUsdtPayments.prepare(any(), any(), anyOrNull(), anyOrNull())).doSuspendableAnswer { + preparing.complete(Unit) + release.await() + Result.success(Unit) + } + val quote = mock() + val send = launch { sut.sendPaykitUsdt(quote) } + preparing.await() + assertTrue(paymentSubmissionActive.value) + assertTrue(runCatching { sut.sendPaykitUsdt(quote) }.exceptionOrNull() is PaykitPaymentRequestError) + whenever(paykitUsdtPayments.send(any(), anyOrNull(), any())).doSuspendableAnswer { + assertTrue(paymentSubmissionActive.value) + Result.success(Unit) + } + release.complete(Unit) + send.join() + assertFalse(paymentSubmissionActive.value) + verify(paykitUsdtPayments).send(eq(quote), eq(null), any()) + } + + @Test + fun `USDT contact submission resumes background work on failure and cancellation`() = test { + setActiveContactPaymentContext(testPublicKey) + whenever(paykitUsdtPayments.prepare(any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.failure(UsdtException.QuoteExpired())) + assertTrue(runCatching { sut.sendPaykitUsdt(mock()) }.exceptionOrNull() is UsdtException.QuoteExpired) + assertFalse(paymentSubmissionActive.value) + + val preparing = CompletableDeferred() + whenever(paykitUsdtPayments.prepare(any(), any(), anyOrNull(), anyOrNull())).doSuspendableAnswer { + preparing.complete(Unit) + awaitCancellation() + } + val send = launch { sut.sendPaykitUsdt(mock()) } + preparing.await() + assertTrue(paymentSubmissionActive.value) + send.cancelAndJoin() + assertFalse(paymentSubmissionActive.value) + verify(paykitUsdtPayments, never()).send(any(), anyOrNull(), any()) + } + + @Test + fun `accepting a USDT request retains its send context until the flow closes`() = test { + val deadline = Clock.System.now() + 3600.seconds + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USDT, 50_000u), + paymentDeadlineAt = deadline, + acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue), + ) + sut.showSheet(Sheet.Send()) + runCurrent() + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + endpoints = listOf( + Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = ""), + ), + ) + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + whenever(paykitUsdtPayments.prepare(any(), any(), anyOrNull(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.accept(request)).doSuspendableAnswer { + pendingPaykitPaymentRequests.value = emptyList() + yield() + Result.success(Unit) + } + setSendState(SendUiState(paykitUsesUsdt = true, paykitAmount = "0.05", isPaymentRequest = true)) + sut.setSendEvent(SendEvent.AmountContinue) + runCurrent() + val quote = mock() + sut.sendPaykitUsdt(quote) + verify(paykitUsdtPayments).send(eq(quote), eq(deadline), any()) + runCurrent() + assertTrue(sut.currentSheet.value is Sheet.Send) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + sut.hideSheet() + assertNull(activeContactPaymentContext()) + } + + @Test + fun `USDT execution rechecks request authorization after acceptance`() = test { + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USDT, 50_000u), + acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue), + ) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + endpoints = listOf( + Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = ""), + ), + ) + pendingPaykitPaymentRequests.value = listOf(request) + setSendState(SendUiState(paykitUsesUsdt = true, paykitAmount = "0.05", isPaymentRequest = true)) + sut.setSendEvent(SendEvent.AmountContinue) + runCurrent() + whenever(paykitUsdtPayments.prepare(any(), any(), anyOrNull(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.accept(request)).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + Result.success(Unit) + } + + val result = runCatching { sut.sendPaykitUsdt(mock()) } + + assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) + verify(paykitPaymentRequestRepo).ensurePaymentAllowed(request) + } + + @Test + fun `a request bound to USDT cannot switch to Bitcoin or prepare hardware payment`() = test { + val request = paymentRequest() + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + whenever(paykitUsdtPayments.hasBinding(request.id)).thenReturn(true) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + usdtRecipient = "0x1111111111111111111111111111111111111111", + paykitUsesUsdt = true, + paykitAmount = "5", + isPaymentRequest = true, + ) + ) + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + runCurrent() + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + assertFalse(sut.prepareHardwareContactPayment()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + } + + @Test + fun `a USDT binding prevents software Bitcoin dispatch when endpoints change`() = test { + val request = paymentRequest() + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + whenever(paykitUsdtPayments.hasBinding(request.id)).thenReturn(true) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = request.amount.atomic, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + ) + ) + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + } private fun privatePaymentContext(version: ULong?) = PrivatePaykitPaymentContext( paymentAppsByEndpoint = mapOf( MethodId.P2wpkh.rawValue to "bitkit", @@ -12002,8 +12958,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun paymentRequest() = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = testPublicKey, - amountValue = "0.000025", - amountSats = 2_500uL, + paymentReference = "test-reference", + amount = PaykitAmount(PaykitAsset.BTC, 2_500uL), expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue, MethodId.P2wpkh.rawValue), ) @@ -12011,8 +12967,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun subscriptionStartingAt(startsAt: Instant) = PaykitSubscription( paymentRequestId = "subscription-id", counterparty = testPublicKey, - amountValue = "0.000025", - amountSats = 2_500uL, + amount = PaykitAmount(PaykitAsset.BTC, 2_500uL), + paymentReference = "fixture-reference", + pricing = PaykitRequestPricing(), note = "Weekly coffee", createdAt = Clock.System.now(), proposalExpiresAt = null, @@ -12052,15 +13009,32 @@ class AppViewModelSendFlowTest : BaseUnitTest() { wasPublishedToActiveState = wasPublishedToActiveState, ) + private fun recurringQuote(now: Instant, rates: List) = PaymentConversionQuoteRecord( + eventId = "original", + billingPeriod = PaykitBillingPeriod(now - 60.seconds, now + 3_600.seconds).sdkValue, + rates = rates, + validFrom = (now - 60.seconds).toString(), + expiresAt = (now + 60.seconds).toString(), + outboundStatus = null, + ) + private suspend fun TestScope.reopenRetainedHardwareRequest( balanceSats: ULong, amountSats: ULong = 1_000uL, + quote: PaymentConversionQuoteRecord? = null, ): Pair { sut.setIsAuthenticated(true) val request = paymentRequest().copy( lifecycleState = PaymentRequestLifecycleState.ACCEPTED, - amountSats = amountSats, - amountValue = java.math.BigDecimal(amountSats.toString()).movePointLeft(8).toPlainString(), + amount = PaykitAmount(PaykitAsset.BTC, amountSats), + pricing = if (quote == null) { + PaykitRequestPricing() + } else { + PaykitRequestPricing(PaymentConversion.PerPeriod, quotes = listOf(quote)) + }, + billingPeriod = quote?.let { + PaykitBillingPeriod(Instant.parse(it.billingPeriod.startsAt), Instant.parse(it.billingPeriod.endsAt)) + }, ) val signed = hardwareSignedReceipt() val proof = to.bitkit.repositories.PendingPaykitPaymentProof( @@ -12069,10 +13043,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, paymentAppId = "bitkit", kind = PaykitPaymentProofKind.Onchain, + conversionQuoteId = quote?.eventId, paymentStarted = true, paymentIdentifier = to.bitkit.utils.SignedTransactionId.fromHex(signed.serializedTx), onchainAddress = REGTEST_ADDRESS, - onchainAmountSats = request.amountSats, + onchainAmountSats = request.amount.atomic, onchainWalletId = HARDWARE_WALLET_ID, hardwareDispatchAttempted = true, hardwareRefusedForNavigation = true, diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index c1c99a8e70..1da38b2a76 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.viewmodels import android.content.Context +import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -24,6 +25,7 @@ import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.UsdtPreferencesRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.test.BaseUnitTest @@ -43,6 +45,12 @@ class SettingsViewModelTest : BaseUnitTest() { private val privatePaykitRepo = mock() private val widgetsStore = mock() private val widgetsRepo = mock() + private val usdtEnabled = MutableStateFlow(false) + private val usdtCanDisable = MutableStateFlow(true) + private val usdtPreferences: UsdtPreferencesRepo = mock { + on { enabled }.thenReturn(usdtEnabled) + on { canDisable }.thenReturn(usdtCanDisable) + } private val contactPaymentSettingsRepo = mock() private val settingsData = MutableStateFlow(SettingsData()) @@ -266,6 +274,28 @@ class SettingsViewModelTest : BaseUnitTest() { assertTrue(settingsData.value.isDevModeEnabled) } + @Test + fun `enabling an empty wallet rechecks the disable switch in the same Settings screen`() = test { + whenever(usdtPreferences.setEnabled(true)).doSuspendableAnswer { + usdtEnabled.value = true + Result.success(Unit) + } + whenever(usdtPreferences.checkCanDisable()).thenReturn(Result.success(Unit)) + sut.canDisableUsdt.test { + assertFalse(awaitItem()) + sut.refreshUsdtAvailability() + advanceUntilIdle() + expectNoEvents() + sut.setUsdtEnabled(true) + advanceUntilIdle() + assertTrue(awaitItem()) + usdtCanDisable.value = false + advanceUntilIdle() + assertFalse(awaitItem()) + } + verify(usdtPreferences).checkCanDisable() + } + private fun createViewModel() = SettingsViewModel( context = context, settingsStore = settingsStore, @@ -273,6 +303,7 @@ class SettingsViewModelTest : BaseUnitTest() { contactPaymentSettingsRepo = contactPaymentSettingsRepo, widgetsStore = widgetsStore, widgetsRepo = widgetsRepo, + usdtPreferences = usdtPreferences, ) private class SettingsViewModelTestError(message: String) : AppError(message) diff --git a/changelog.d/next/1432.added.md b/changelog.d/next/1432.added.md new file mode 100644 index 0000000000..b2899ea9a0 --- /dev/null +++ b/changelog.d/next/1432.added.md @@ -0,0 +1 @@ +Opt in to USDT in Settings to send and receive with fees paid in USDT, Paykit contact payments and requests, subscriptions, cross-network deposits, optional Earn address sharing, and encrypted payment recovery backups. diff --git a/docs/earn-receiving-details.md b/docs/earn-receiving-details.md new file mode 100644 index 0000000000..d404417e33 --- /dev/null +++ b/docs/earn-receiving-details.md @@ -0,0 +1,29 @@ +# Earn receiving-details claims + +Earn services request receiving details through Pubky Auth. USDT sharing is optional and uses the existing Arbitrum One account; it creates no account, enables no public-sharing setting, and grants no spending authority. Services see that address's public balance and history. + +Use exactly one `x-bitkit-claim` parameter containing a dot-separated list of independent permissions: + +| Permission | Shared authority/details | +| --- | --- | +| `watch-only-account-v1` | Separate native-SegWit Bitcoin account | +| `paykit-access-v1` | Generation-bound Paykit identity secret, never a wallet spending key | +| `usdt-address-v1` | Existing USDT0 Arbitrum One address, if approved | + +For example, `paykit-access-v1.watch-only-account-v1.usdt-address-v1` requests all three. Preserve the exact received order for signature and channel derivation. Unknown, duplicate or empty items are rejected. Requests require `/pub/paykit/:rw`. + +The existing Earn introduction leads to authorization review. USDT has an optional sharing toggle and expandable/copyable address. If loading fails, the user may retry or authorize without USDT. When selected, Bitkit re-reads the address and checks it matches the reviewed address. Canceling before authorization shares nothing. Bitcoin retains its separate account allocation and retry/recovery lifecycle. + +## Payload and authentication + +Selections containing USDT use a UTF-8 JSON object. Include only requested and approved fields: + +- `usdt-arbitrum-address`: `{value, chain_id: "42161", token: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"}`. Omit this field when declined. +- `bitcoin_account`: `{account_index, address_type: "nativeSegwit", xpub}` when Bitcoin is requested. No wallet-local names or IDs. +- `paykit_access`: `{key_generation, secret}` when Paykit access is requested. The generation is an exact nonzero unsigned 64-bit integer; the 32-byte secret uses unpadded base64url. + +Selections without USDT retain the existing binary layouts: Bitcoin 84 bytes, Paykit 41 bytes, both 124 bytes. Those are independent supported permissions, not USDT compatibility paths. + +Paykit signs UTF-8 `x-bitkit-claim||`, SHA256 of the 32-byte AUTH secret, and the unsigned payload. It appends the 64-byte Ed25519 signature and uses its XSalsa20-Poly1305 companion transport. The relay channel is unpadded base64url of BLAKE3 over UTF-8 `|` followed by the AUTH secret. Verify the received bytes before parsing; never reserialize JSON for signature verification. + +The Creator's authenticated Pubky key verifies the claim. This binds receiving details to the identity and request, not ownership of the EVM private key. The server validates the pinned chain/token, address and Bitcoin account before persistence, and verifies delegated Paykit authority against the identity-signed Noise key authorization. A matching server can request USDT only when configured to store and verify it. Declining USDT leaves Bitcoin setup usable. Reconnect may add a missing USDT address while preserving existing receiving bindings. diff --git a/docs/screens-map.md b/docs/screens-map.md index bac060af52..31112fae9a 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -70,7 +70,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | -| CreatePaymentRequestScreen.kt | Payment Request (from Receive > Contacts OR Contact profile OR Payment Requests) › Request payment enter amount | +| CreatePaymentRequestScreen.kt | Payment Request (from Receive > Contacts OR Contact profile OR Payment Requests) › Request payment enter amount / Payment Request (from Edit invoice) › [Request payment details](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=49115-131077) | | IncomingPaymentRequestDetailsScreen.kt | Subscriptions › Payment Request Details | | PaymentRequestsScreen.kt | Payment Requests › Payment Requests | @@ -121,7 +121,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | -| CreateSubscriptionScreen.kt | Subscriptions › Create Subscription / Choose Subscription Recipient / Sent Subscription Proposal | +| CreateSubscriptionScreen.kt | Subscriptions › [Create Subscription](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=49027-623659) / Choose Subscription Recipient / Sent Subscription Proposal | | SubscriptionsScreen.kt | Subscriptions › Subscriptions Intro / Subscriptions overview | ## ui/screens/transfer @@ -221,6 +221,14 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | - | - | | BuyIntroScreen.kt | Buy Bitcoin › Buy Bitcoin | +## ui/screens/wallets/usdt + +| Android | Figma | +| - | - | +| UsdtDepositReceiveScreen.kt | Receive USDT › HW QR / Recipient / Specify Amount (populated) / Fee To Add Liquidity | +| UsdtDepositsScreen.kt | Activity › Transaction Cross Network USDT / Transaction Receiving USDT Tech details | +| UsdtWalletScreen.kt | Savings, Spending, Hardware, USDT Balances › Wallet USDT (funds & activity) / Wallet USDT (empty) | + ## ui/screens/wallets/withdraw | Android | Figma | @@ -346,3 +354,15 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | - | - | | CustomFeeSettingsScreen.kt | Send (Paste) (On-chain) › Set custom fee | | TransactionSpeedSettingsScreen.kt | Settings > General › Speed | + +### USDT receive + +[Figma](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=49861-374947): +Receive Dollars, network selection, amount entry, estimated fees, QR and address details share the existing Receive sheet. +Amount entry stays in dollars with a BTC equivalent; QR and shared payment amounts preserve USDT precision. See `journeys/usdt-receive-amount-and-units.xml`. + +### USDT opt-in and network selection + +`UsdtIntroSheet.kt` implements [New: USDT](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=50228-146387). `SettingsScreen.kt` includes the [USDT (Tether) switch](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=48063-277709). Both are covered by `journeys/usdt-opt-in.xml`. + +`UsdtNetworkPicker.kt` shares the receive network rows with the [send network sheet](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=50231-146880); all available routes remain scrollable. diff --git a/docs/usdt-qa.md b/docs/usdt-qa.md new file mode 100644 index 0000000000..c26535e5ce --- /dev/null +++ b/docs/usdt-qa.md @@ -0,0 +1,104 @@ +# USDT manual integration checks + +The journey environment does not provide funded Arbitrum or cross-network accounts, controlled USDT provider/RPC/bundler responses, encrypted VSS outages, or a subscription billing clock. These checks require the named external setup. Record the build, configuration, actual transaction evidence, and whether evidence came from a fixture or a live service. Wallet resets and transfers require the owner’s authorization. + +The drivable wallet navigation and request/subscription UI paths are in `journeys/usdt-wallet-navigation.xml` and `journeys/paykit-quoted-usdt.xml`. The manual checks below are specifications, not evidence of execution. + +Enable USDT through its introduction or Settings before starting payment checks. The opt-in, disable safeguards and disabled-link paths are covered by `journeys/usdt-opt-in.xml`. + +## usdt bridge quotes + +A funded Arbitrum wallet uses the real gateway with USDT0 and Orchestra enabled. This journey obtains quotes only; do not swipe to pay or create any funded order. + +- [ ] Open USDT from the home balance, then Send. Verify UsdtNetwork offers Arbitrum and the enabled provider destinations. +- [ ] Choose Base, open manual entry and enter an owned EVM address. Enter an affordable USDT amount and continue to review. +- [ ] Verify the review displays Base, the selected Orchestra provider in details, estimated destination receipt, included bridge cost, maximum source fee and maximum total debit. All amounts use USDT. +- [ ] Close without paying. Start a send to Polygon and obtain a quote to an owned address. Verify the selected provider is shown and the same amount and fee fields remain clear. +- [ ] Close without paying. Select Solana and verify an EVM address is rejected, then select Tron and verify an EVM address is rejected. +- [ ] Close Send and verify the home balance has not changed and no outgoing payment was created. + +## usdt outbound bridge + +USDT is configured against the real gateway; both app and service enable Polygon through USDT_BRIDGE_NETWORKS, and USDT_BRIDGES_URL enables Orchestra destinations. Use a funded wallet and an owned destination address. Mainnet transfers require authorization. Preserve wallet data, keep authentication out of recordings, and record actual transaction hashes and fees. + +- [ ] Open the home screen and verify the USDT balance, then open USDT and Send. +- [ ] Choose Polygon in UsdtNetwork and open manual address entry. Verify an invalid address cannot advance. Enter an Arbitrum-specific payment URI and verify the selected network changes to Arbitrum and uses the URI recipient and amount. +- [ ] Change to Polygon from amount entry. Verify the valid EVM recipient and entered amount are retained. Change to Solana and verify a compatible recipient is required; entering a plain Solana address preserves the amount, while a supported payment URI with an explicit amount replaces it. +- [ ] Enter the owned destination address and a small USDT amount. Verify the review shows Polygon, the full recipient, selected provider, estimated received amount, included Orchestra routing cost where applicable, additional maximum source fee and maximum total debit in USDT. +- [ ] Authorize one payment. Verify it appears in activity as awaiting cross-network delivery rather than claiming delivery from source-chain execution alone. +- [ ] Restart the app and verify the same transfer continues tracking without sending a second payment. +- [ ] Verify destination delivery independently from its transaction receipt, token, recipient, amount and balance. Confirm activity details update and the Arbitrum wallet retains zero ETH. +- [ ] Return home and verify the updated USDT balance. Verify Paykit payment choices remain Arbitrum-only. + +## usdt cross-network receive + +Requires a wallet with USDT enabled and a matching backend with Orchestra credentials and accepted source networks enabled. Address and estimate checks do not move funds. Externally funded settlement and provider-held refunds require separate manual tests. + +- [ ] Open the USDT wallet and Receive. Verify direct Arbitrum One receiving is available. +- [ ] Open the network selector. Verify only configured supported sources are offered: Ethereum, Tron, Solana, Polygon, Base and BNB Smart Chain. +- [ ] Select each enabled source network, enter an amount within its live limits, and get its deposit address. Verify the selected network, reusable address, estimated received amount and deduction are shown. Verify the QR and Copy action use the same address. +- [ ] Return to Arbitrum One and verify its original wallet address remains unchanged. +- [ ] Open cross-network deposit activity. Verify empty history and Load more work without showing a failure. +- [ ] Enter an amount below the route minimum and verify the limit error appears without a deposit address. +- [ ] Edit the amount. Verify the old limit error clears immediately and the keypad and button remain fully visible. Get an address with an amount within the route limits. +- [ ] Reopen the app and verify the reusable address and deposit history are recovered. Inspect any existing deposit details without claiming a new settlement. + +## usdt payment recovery + +Two linked wallets with USDT enabled and separately authorized payment funds. Preserve wallet data. This covers normal UI restart recovery; controlled VSS/RPC/bundler outages, missing storage and cross-platform backup injection require separate manual tests. + +- [ ] Create a USD subscription payable in USDT and open an installment payment review. +- [ ] Verify recipient, amount and maximum fee, then authorize one installment payment. +- [ ] Once dismissal is permitted, close the payment sheet and reopen the USDT wallet. Record the displayed transfer and request attribution. +- [ ] Restart the app and reopen that transfer. Verify it retains the same activity entry and request attribution without asking for a second payment approval. +- [ ] If the transfer is still pending, try to cancel its subscription. Verify unresolved payment execution prevents cancellation. +- [ ] Refresh until the transfer settles or reports a terminal outcome. Verify its activity details show that outcome and no second outgoing transfer was created. + +## Paykit settlement, later installments and evidence + +Requires funded Arbitrum accounts, controlled billing time, and controlled receipt evidence, none of which is listed in Capabilities. + +- [ ] Open the home screen and verify the USDT balance is visible and a funded USDT wallet does not show empty-wallet onboarding. +- [ ] Continue to review and verify recipient, amount and additional USDT fee before authorizing payment. +- [ ] Complete payment with the receiver viewing Subscriptions and verify success, green receive celebration, activity details and the actual received amount on both wallets. +- [ ] Restart both apps and verify the same payment history and request attribution, with no second payment. +- [ ] Create a BTC request accepting Bitcoin and USDT. Switch the payer between Savings, Spending when enabled, and USDT; verify the requested value is preserved and the quoted amount and fee appear before approval. +- [ ] Using a controlled billing clock, advance to the next installment with the requester offline. Verify its USD amount still maps exactly to USDT, the fee remains additional, and earlier paid periods remain unchanged. +- [ ] Using controlled payment evidence, verify underpayment and payment after expiry show actual received funds and the corresponding status, without reporting the transfer as failed. +- [ ] In the local test setup, prepare a recurring request whose conversion quote expires before the request deadline. Wait for quote expiry before submitting through Savings, Spending and a prepared hardware-wallet retry. Verify no new payment is dispatched; any previously attempted transaction remains recoverable under its original quote. +- [ ] Pay a contact directly without a request and verify the normal asset selector, send review and activity attribution still work. Verify selecting USDT does not request camera access for an already resolved recipient. + +## Backup and dependency failures + +Requires disposable wallets and controlled VSS/RPC/bundler/storage endpoints; no Android device harness providing these controls is supplied here. + +- [ ] Make VSS unavailable before approving one payment. Verify no bundler submission. If core has recorded the operation, the send screen must track that payment without offering another Continue or approval. Otherwise it must show the backup error. Restore VSS and verify only the original approved operation continues, with one activity entry. +- [ ] Restore its encrypted envelope on the opposite platform with the same authorized seed. Verify one operation, request attribution and proof delivery, without a second approval or transfer. +- [ ] Restore with unavailable local storage. Verify restore remains incomplete and no replacement wallet backup is uploaded. + +## USDT design and receive estimates + +Reference: [USDT designs](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=48063-274766). + +- [ ] Check funded and empty Home and USDT wallet layouts; the USDT accent is #009393 throughout receive, send, Paykit, sharing and celebrations. +- [ ] In Receive, open Network and select an enabled source. Verify the minimum-amount error is compact and clears on editing. +- [ ] Enter an accepted dollar amount and verify its BTC equivalent. Continue and verify Estimated Fees shows the expected deduction and received amount, then continues to the QR for that network. +- [ ] Open address details, verify Copy and QR target the same address, and return to the QR. +- [ ] Back out of an unprepared network and verify the displayed network always matches the QR. Return to Arbitrum and verify its original wallet address. +- [ ] With an authorized incoming transfer or an explicitly identified fixture, verify teal receive confetti, Details and OK; Details opens that transfer. +- [ ] With an authorized send or an explicitly identified fixture, check the swipe after warning and PIN prompts: loading and the completed checkmark stay at the right end, the swipe label is hidden, and confirmation keeps its side spacing until the success screen appears. +- [ ] Verify bridge review, estimated fees and deposit history round displayed amounts to cents without changing the exact payment, QR or support-export values. Check a nonzero sub-cent value displays <0.01. +- [ ] Open a payment-request review and verify From and For icons use the selected payment method’s color: orange for Savings, purple for Spending and teal for USDT. + +## Paykit quoted reviews and history + +Requires two linked owned wallets and funded payment rails: Arbitrum One USDT or the local Arbitrum fork fixture for USDT review, and usable Bitcoin balances for Bitcoin review. A Bitcoin regtest balance does not fund USDT. The differing-rate checks require a controlled request issuer and BTC/USD rate source; proof-status checks require controlled receipt evidence. No payment is submitted by the review steps. + +- [ ] Open an unpaid USD request accepting only USDT. Continue from Amount to review. Verify Payment Request, the requester avatar, From and For summary, the exact dollar amount and additional fee. Open details and verify Contact, recipient address and invoice note. Close without swiping to pay. +- [ ] Open a quoted request for 1000 USD requiring 1 BTC while Bitkit's controlled BTC/USD rate is 2000. Verify details label 1000 USD as Requested amount, while payment review shows 1 BTC and Bitkit's approximately 2000 USD valuation. Close without paying. +- [ ] Open a BTC request with different quoted prices for Bitcoin and Lightning. Switch Savings and Spending and verify each method's payable BTC amount, Bitkit's own fiat valuation and explicit payment approval. Close without paying. +- [ ] Open a linked USDT-only contact with a published private payment list, enter 0.01 USDT and continue to review. Verify the owned recipient address and fee, then close without paying. +- [ ] Using an existing request paid in USDT, open history on payer and requester. Verify the sent or received arrow, its background and the date/time icons use USDT teal even when the request denomination is BTC or USD. +- [ ] In request history, verify an unverified delivered proof says Proof submitted, not Paid. A received USDT request stays Pending until its matching proof is verified, then shows Payment verified, Received underpaid or Received after expiry with the actual USDT amount and no conflicting proof-delivery label. + +- [ ] On a received USDT celebration with available details, tap the amount and verify it opens the same activity as Details. diff --git a/docs/usdt.md b/docs/usdt.md new file mode 100644 index 0000000000..4fcdb51f35 --- /dev/null +++ b/docs/usdt.md @@ -0,0 +1,76 @@ +# USDT configuration + +Normal builds use published bitkit-core `0.8.0-rc6`, including its matching native libraries and generated bindings. + +USDT uses Arbitrum One regardless of the Bitcoin network setting. Builds use the credential-free Bitkit gateway by default: + +| Setting | Default | +| --- | --- | +| `USDT_BRIDGES_URL` | `https://bitkit.to/v1/usdt/bridges` | +| `USDT_BUNDLER_URL` | `https://bitkit.to/v1/usdt/rpc` | +| `USDT_DEPOSITS_URL` | `https://bitkit.to/v1/usdt/deposits` | +| `USDT_RPC_URL` | `https://bitkit.to/v1/usdt/chain-rpc` | + +Gradle properties, environment variables or `local.properties` override these defaults. For local E2E testing, override all four URLs with the matching local service routes. Clearing the RPC or bundler build setting disables USDT network operations; local recovery-data backup and restore remain available. Clearing the deposits or bridges setting disables that provider API. + +Optional `USDT_DEPOSITS_URL` enables wallet-signed Orchestra deposits. Provider keys remain on the service. Source support covers Ethereum, Tron, Solana, Polygon, Base and BNB Smart Chain, subject to explicit service enablement and live route availability. Each source requires funded acceptance before release. Users approve a source-network refund address when requesting an eligible refund; unconverted Tron refunds require provider support. History and addresses recover under the same provider partner account. Linked order amounts are batch totals. Outbound USDT0 destinations are enabled with `USDT_BRIDGE_NETWORKS`, a comma-separated subset of `ethereum,polygon,plasma,stable`, matching the service configuration. An empty USDT0 network list disables USDT0 bridging; Orchestra destinations are discovered separately through `USDT_BRIDGES_URL`. Paykit always uses direct Arbitrum payments. + +The `bitkitCoreAar` Gradle property selects an absolute local release AAR. Bindings and native libraries must come from the same core build. + +For a local emulator gateway, use loopback URLs and ADB reverse port forwarding. Core allows HTTP only on localhost or 127.0.0.1; the emulator host alias 10.0.2.2 requires HTTPS. + +USDT follows the existing UI → ViewModel → repository → Rust ownership. The repository manages foreground refresh, receive/pending refresh and rate-limit backoff. Native payment authentication precedes signing. Transaction IDs and explorer links appear when a transaction hash is available; Arbiscan links are built by the app. Failed/replaced payment details do not claim recipient delivery. Wallet wipe closes core handles before deleting wallet-specific USDT files. + +Protocol and recovery behavior belong to bitkit-core's USDT module documentation; provider deployment belongs to the service documentation. + +USDT amounts can be entered with up to six decimal places. Overview and review amounts display cents; formatting does not change the exact transfer amount. + +MAX reserves the quoted USDT fee and reuses that funded quote for payment review. It requires the gateway to support Core's pinned USDT balance override during gas estimation. That override is not used for final authorization or transaction submission. + +History catch-up is deferred while the USDT send sheet is open. Balance and pending-payment recovery continue; a history scan already in progress is allowed to finish. Unknown deposit-provider statuses require attention instead of appearing as ordinary pending deposits. Cross-network payment details label the recipient amount as expected until delivery is confirmed. + +## Enabling USDT + +Unused wallets do not create USDT storage or poll the gateway merely for Home refreshes or backups. Existing wallet journals still allow payment/proof recovery while USDT is off. + +USDT starts off. The introductory sheet offers Enable USDT and No Thanks; dismissing it without a choice allows it to return next launch. Settings > General > Payments > USDT (Tether) changes the choice later. + +When off, USDT is hidden from new payment choices, Paykit offers and authorization details. Combined Bitcoin and USDT authorization continues with Bitcoin and omits the USDT address; a USDT-only authorization is unavailable. Authorization never asks to enable USDT. Automatic clipboard detection ignores USDT addresses and links while retaining Bitcoin and Lightning detection. + +Explicitly opening a USDT link or request, or scanning its QR, offers the USDT enable sheet. Enabling resumes the original flow; declining cancels it quietly. Funds or unresolved transfers keep USDT enabled, with the Settings switch on and greyed out. Disabling requires a fresh empty balance and no unresolved transfers or deposits. Tracking, history and encrypted backups continue, including after restore. Previously shared addresses cannot be revoked. If a declined wallet has settled history but its balance is unavailable, history remains visible without re-enabling receiving options. + +## Paykit + +USDT request sends pass the earlier of the approved conversion quote expiry and request payment deadline to Core as the original precise timestamp. Core persists that cutoff with the signed operation and enforces it before the initial submission and recovery retries. Expired pending operations remain tracked for settlement evidence. Operations created before deadline support have no stored cutoff and retain their existing recovery behavior. + +Paykit uses a direct Arbitrum USDT0 endpoint (`usdt-arbitrum-address`). Existing public/private sharing settings control publication. The payload binds the address, chain 42161 and pinned token; bridge routes are excluded from Paykit choices. + +Bitkit creates BTC or USD requests and also reads USDT-denominated requests from other wallets. The accepted methods are immutable request terms. Every accepted cross-asset currency has an explicit requester-supplied rate; the denominating asset needs no conversion. USD/USDT is exactly 1:1. Bitcoin rates come from the existing BTC/USD source when issuing terms, not when verifying received funds. External requests without rates allow only same-asset payment. A listed method without its required rate cannot be used. + +Required amounts are rounded upward once to Bitkit's payment precision (satoshis for Bitcoin and six decimal places for USDT). Fees are additional. Approval pins the amount, quote identifier and validity interval; changes requiring new approval return through review. Receipt validation uses those terms with no percentage tolerance or later market repricing. + +One-time requests use the selected expiry as both the acceptance limit and actual payment deadline. Bitkit-created subscriptions accept only enabled Bitcoin methods (Savings/Spending) for BTC amounts, or USDT for USD amounts. USD subscriptions include a fixed 1:1 USDT rate for every installment; BTC subscriptions need no conversion. No recurring quote publisher or requester availability is needed to price later installments. External per-period quotes and absolute or period-relative deadlines are still honored, and existing subscription terms and proof history are not rewritten. Each installment requires payer approval; accepting a subscription does not authorize automatic payments. + +Before sending USDT, the app durably associates the request and billing period with the core payment ID and immutable proof binding. After successful execution, core signs the released `erc20-transfer-eip712` proof over both participants, the receiving payment app, request reference, endpoint, billing period and selected quote. The full proof envelope is size-checked before sending. Retries and restarts deliver the same proof without another transfer; a replacement payment is allowed only for an unstarted attempt without a core operation or a definitively failed/replaced operation. Wallet backups retain these associations, started-payment markers, received-payment identities and selected conversion quotes; missing execution history after restore never authorizes a replacement payment. + +Receipts require successful canonical execution, the correct chain/token/recipient and the transfer sender's signature. A payment is identified by verified chain, transaction and original receipt-log position and can satisfy at most one request/period. Real funds are displayed as received, underpaid or after expiry, with their actual amount. Overpayment is accepted; underpayment and late payment do not start a dispute, refund or top-up workflow. Unavailable evidence remains pending; a previously verified receipt is rechecked without discarding its attribution. A submitted USDT proof remains visible while verification is pending; only a verified, sufficiently funded, timely receipt advances a received subscription's paid count and paid-through date. Bitcoin retains its existing settlement behavior. + +## Encrypted recovery backups + +The wallet backup includes core's portable USDT recovery snapshot alongside Paykit request bindings, pending proofs and received-payment identities. It contains signed operations and stable activity IDs, but no private keys or seed. The existing VSS client encrypts and authenticates the complete envelope. History cursors and unsigned quotes are rebuilt locally. + +Core waits for the app to acknowledge a VSS upload before first submission or automatic rebroadcast. If backup is unavailable, the signed payment stays pending locally and no submission is attempted; recovery retries the same operation once backup is available, or reconciles its expiry against the chain. Wallet uploads are serialized so an older background snapshot cannot replace a payment's acknowledged backup. + +Restore merges records for the derived account without overwriting newer local outcomes. Signed operations imported into an empty database are reconciled against chain evidence before retry; already discovered transactions retain their restored application IDs. A failed restore retains the downloaded envelope and blocks replacement backups. Bitcoin-only backups remain readable. Seed-only recovery restores account control and chain history, but cannot reconstruct a never-mined signed operation or its Paykit attribution. Missing evidence never authorizes a second payment. + +A backup containing USDT recovery data requires a build with USDT configured to complete the restore. A build with USDT compiled out retains the downloaded recovery envelope and keeps replacement backups blocked; it does not discard the USDT state to finish a Bitcoin-only restore. Resume with a compatible USDT-enabled build. Turning USDT off in Settings is separate from this build limitation and does not remove recovery data. + +## Outbound provider selection + +Configure `USDT_BRIDGES_URL` with the gateway's credential-free `/v1/usdt/bridges` route to enable Orchestra. The network selector combines the gateway's enabled Orchestra destinations with the app's `USDT_BRIDGE_NETWORKS` USDT0 destinations. Direct Arbitrum and Paykit remain unchanged. Core chooses the best usable estimated receipt per maximum total source debit, including the source paymaster fee; a tie prefers USDT0. + +Orchestra routing costs are deducted from the entered USDT principal. Review shows estimated destination receipt, included route cost, additional maximum source fee and maximum total debit. Fees remain in USDT, including when the provider uses intermediate assets. The chosen quote is frozen before signing. Funding and delivery are separate: activity retains the original recipient and network through pending delivery, provider attention and a verified Arbitrum refund. A timeout, restart or provider failure never starts a payment through another provider. Encrypted backup includes the funding plan and tracking ticket; seed-only history cannot reconstruct them. + +Direct contact sends retain the contact on the sending wallet. A direct incoming transfer to the shared USDT address has no authenticated Paykit contact identity and is displayed without a contact. Incoming request payments gain attribution only after the request-bound proof and on-chain transfer are verified; the address or an unverified sender claim is not sufficient. + +“Proof submitted” describes Paykit proof delivery, not independent payment verification. A matching verified USDT receipt supplies the received, underpaid or late-payment status. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec7f9f9dd9..4c8e652319 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.5.18" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc6" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc74" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } diff --git a/journeys/README.md b/journeys/README.md index 0a90b12553..8f9d16b3ae 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -93,6 +93,8 @@ dotted strings such as host IPs are where the dropping shows up first. ## Backend preconditions +In a build with USDT configured, resolve the introductory USDT sheet by enabling or declining it before starting an unrelated journey. The choice persists; a fresh undecided wallet can show the sheet after Home appears. USDT journeys state whether they require opt-in. + The dev flavor targets the **staging regtest** backend (`Env.kt` points regtest at `*.stag0.blocktank.to`), so most journeys need nothing running locally beyond the app. Fund and mine through the `lsp` helper at the repo root, which talks to the same staging LSP: @@ -121,6 +123,8 @@ journey PR, which is what made this file conflict on every merge. | Capability | Provided by | | --- | --- | +| USDT wallet navigation without funding | an onboarded build with USDT configured; provider errors may remain visible while offline — [USDT QA](../docs/usdt-qa.md) | +| Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../docs/usdt-qa.md) | | On-chain funds and blocks on regtest | `./lsp` deposit and mine against the staging LSP — [Backend preconditions](#backend-preconditions) | | Several separate on-chain UTXOs to choose between | three or more `./lsp` deposits, each mined, so manual coin selection has inputs to list — [Backend preconditions](#backend-preconditions) | | Lightning channels, CJIT orders and quoted maxima | the same staging LSP the dev flavor targets, plus its node as an external LN peer — [Backend preconditions](#backend-preconditions), [amount-limits](amount-limits/README.md) | @@ -155,6 +159,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `coin-selection/manual-wallet-switch.xml` | same file, journey name and actions; iOS can open the picker on the wallet switch, Android on the next swipe. The iOS picker has no accessibility identifiers, so assert its visible "Coin Selection" title. | | `payment-requests/requested-resolution-failure.xml` | same file and journey name; both platforms keep the preparing confirmation open across retries and stop automatic retries when it closes. Native progress and request-row identifiers differ. Android's subscription-reminder preparation has no intermediate Send sheet; see `paykit-clock-changes.md`. | | `payment-requests/request-summary.xml` | same file and journey name; Android additionally checks the open Sent receipt's live lifecycle subtitle after payment. iOS keeps a static note/date receipt without that subtitle, so those checks are not ported. | +| `usdt-payment-restoration.xml` | same file, journey name and actions; Android restores an unpaid amount draft after process death, while iOS may return to the wallet. A submitted payment must return to the wallet without reopening the paid draft on either platform. | | `payment-requests/success-dismissal.xml` | same file, journey name and actions; both platforms dismiss successful and unpaid requests without a send error or duplicate payment. | | `payment-requests/minimum-onchain-amount.xml` | shared — minimum amount feedback, preparation cleanup, and manual retry use the same journey and `PaymentRequestAmountTooLowToast` identifier on both platforms. | | No matching intermediate return route | `amount-limits/custom-fee-wallet-switch.xml` is iOS-only: Custom returns through the fee picker before confirmation. Android's `setTransactionSpeed` returns directly to confirmation. | diff --git a/journeys/hardware-wallet/send-onchain.xml b/journeys/hardware-wallet/send-onchain.xml index b73b9fc903..c5050afb8b 100644 --- a/journeys/hardware-wallet/send-onchain.xml +++ b/journeys/hardware-wallet/send-onchain.xml @@ -23,8 +23,9 @@ Verify the Send amount screen is visible (testTag "send_amount_screen") - Tap the funding-source button (testTag "AssetButton-switch"), waiting for any balance loading - to finish between taps, until it shows the paired Trezor name in blue + Tap the funding-source button (testTag "AssetButton-switch") once, verify the source menu + (testTag "SendFundingSources") opens, then select the paired Trezor row. Wait for balance + loading to finish and verify the button shows the paired Trezor name in blue Verify AVAILABLE shows a positive Trezor balance, then enter a valid amount smaller than it diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml new file mode 100644 index 0000000000..bd97b2a6fa --- /dev/null +++ b/journeys/paykit-quoted-usdt.xml @@ -0,0 +1,21 @@ + + Two linked Bitkit wallets with Paykit and USDT enabled, sharing enabled by their owners, and a fresh BTC/USD rate. These request-creation and amount-screen checks need no funds and stop before payment review. Funded Arbitrum review, paid history, controlled rates and proof verification are manual tests in Docs/USDT-QA.md; a Bitcoin regtest balance does not fund USDT. + + Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. + Open a linked contact and create a USD payment request. Verify BTC and USD are the denomination choices and enabled receiving methods start selected. Verify Accept Payment In appears below expiry, with compact outlined Bitcoin and USDT chips. Bitcoin groups all available Savings and Spending methods; no separate rail chips appear here. + On a comma-decimal device locale, enter 150,50 USD while creating a payment request. Verify the keypad, entered amount, request review and recipient row all show exactly 150,50 USD. Go back and verify the amount and denomination are preserved. On iOS with Classic Bitcoin display, repeat with a BTC amount and verify the keypad and amount use a dot and returning from review preserves the amount. + Return to the request amount editor, select USD and replace the amount with 10. Switch to BTC and back to USD four times without editing. + Verify the USD amount is still exactly 10 and each return to BTC shows the same satoshi amount. + Clear the USD input, then select BTC. Verify the amount is zero and Continue is disabled. + Select USD and enter 5 followed by the decimal separator without a fractional digit. Verify Continue is enabled. Continue and verify the request review shows 5,00 USD on a comma-decimal locale or 5.00 USD on a dot-decimal locale. + Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved. + On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Close without continuing to review. + Reopen the unpaid USDT request and wait for its Amount screen. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying. + Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment. + Create a BTC request accepting Bitcoin and USDT. On the payer's Amount screen, switch between available Savings, Spending and USDT sources. Verify the displayed amount preserves the request's quoted value and cannot be edited. Close without continuing. + Create a USD subscription for 150,50 on a comma-decimal locale. Verify the amount review shows 150,50 USD and editing preserves that amount. Verify Accepted Payments appears below frequency, with USDT selected and Bitcoin unselected. Verify the description and icon controls are side by side. Propose it to the linked contact. On the payer, verify the subscription card and details show 150,50 USD, then review its recurrence and USDT payment method. Verify the subscription swipe uses USDT teal, then close before subscribing. + Create a BTC subscription for 12,345 sats and verify Bitcoin is selected. Tap SubscriptionAccept-usdt; verify the amount converts to USD and only USDT is selected. Tap SubscriptionAccept-bitcoin; verify the amount returns to exactly 12,345 sats and only Bitcoin is selected, covering the enabled Savings and Spending methods. + Repeat the subscription USDT and Bitcoin switches four times without editing. Verify every return to Bitcoin shows exactly 12,345 sats. Edit the amount to 10 USD, repeat the switches and verify every return to USDT shows exactly 10 USD. Close without proposing. + Open a linked USDT-only contact that has published a private payment list. Verify the amount screen offers only USDT, enter 0.01, verify the USDT balance is labeled AVAILABLE rather than AVAILABLE (SPENDING), then close without continuing to review. + + diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 9ab8dc26d9..0916655715 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -5,8 +5,10 @@ Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract [`app/src/test/resources/paykit-issuer-interoperability.json`](../../app/src/test/resources/paykit-issuer-interoperability.json). `fixed-price-bitcoin.xml` verifies a USD-denominated request settled in BTC and an explicit -same-asset rail price. The confirmation amount uses the issuer's fixed rate; the displayed fiat -estimate uses Bitkit's own market rate. The flow stops before broadcasting. Differing rail prices, +same-asset rail price. After preparation, the Amount screen shows the fixed, uneditable payment +amount with Savings selected. Continue opens confirmation with the same amount. The amount uses +the issuer's fixed rate; the displayed fiat estimate uses Bitkit's own market rate. The flow stops +before broadcasting. Differing rail prices, fractional-satoshi Lightning payments, and dynamic quotes remain unsupported. ## Setup diff --git a/journeys/payment-requests/fixed-price-bitcoin.xml b/journeys/payment-requests/fixed-price-bitcoin.xml index aa6af658bb..138c2a1cae 100644 --- a/journeys/payment-requests/fixed-price-bitcoin.xml +++ b/journeys/payment-requests/fixed-price-bitcoin.xml @@ -6,12 +6,18 @@ Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} Have the issuer send a new one-time request for amount "10", asset "usd", accepted identifier "btc-regtest-p2wpkh", and fixed conversion rate "btc" equal to "0.000021" - Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 21,000 sats, not 10 BTC + Wait for request preparation to finish and the Amount screen to open with Savings selected + Switch the amount display to Bitcoin if needed + Verify the requested amount is 21,000 sats, cannot be edited, and does not show 10 BTC + Tap Continue and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 21,000 sats Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control (id "GRAB") stops showing progress Verify the fiat estimate equals Bitkit's current local valuation of 21,000 sats, rather than being forced to the issuer's 10 USD denomination Dismiss the Payment Request confirmation screen without paying Have the issuer send a new one-time request for amount "0.001", asset "btc", accepted identifier "btc-regtest-p2wpkh", and fixed rates "btc" equal to "2" and "btc-regtest" equal to "0.5" - Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 50,000 sats, not 100,000 or 200,000 sats + Wait for request preparation to finish and the Amount screen to open with Savings selected + Switch the amount display to Bitcoin if needed + Verify the requested amount is 50,000 sats, cannot be edited, and does not show 100,000 or 200,000 sats + Tap Continue and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") still shows 50,000 sats Dismiss the Payment Request confirmation screen without paying diff --git a/journeys/pubky-auth/share-receiving-details.xml b/journeys/pubky-auth/share-receiving-details.xml new file mode 100644 index 0000000000..12d4f4e2c6 --- /dev/null +++ b/journeys/pubky-auth/share-receiving-details.xml @@ -0,0 +1,24 @@ + + Use an onboarded empty wallet with a Pubky identity and configured USDT. Enable USDT in Settings for the first checks. Use a local test auth request; cancel before Authorize so no receiving details or session are exported. + + Open the existing watch-only setup fixture with x-bitkit-claim=usdt-address-v1 + Verify the Earn Money introduction describes sharing the existing USDT address on Arbitrum One, with the balance/history disclosure and no optional sharing switch. Tap Approve + Verify the authorization screen shows the requested permissions and trust warning without repeating the receiving-details controls + Cancel and verify no Bitcoin account was created + Repeat with x-bitkit-claim=watch-only-account-v1.usdt-address-v1; verify the Earn Money introduction describes the Bitcoin watch-only account and shows the blue "Also share my USDT address" switch (id "PubkyAuthShareUsdt"). Turn sharing off and verify its privacy disclosure disappears. Tap Approve, then Back; verify the choice remains off. Cancel + Repeat with x-bitkit-claim=watch-only-account-v1; verify only Bitcoin is requested, then cancel + With USDT enabled but its address temporarily unavailable, open a combined Bitcoin and USDT request. Verify sharing is off and Approve remains available; turn USDT sharing on and verify Approve is disabled until Retry loads the address + Open the setup fixture with x-bitkit-claim=paykit-access-v1.watch-only-account-v1.usdt-address-v1 + Verify the introduction shows the optional USDT sharing switch and selected-address privacy disclosure. Continue to authorization; verify the named requester, exact requested permission paths and access, and Paykit Details explanation are shown without a second sharing switch or address panel + Verify Before You Continue explains trusting the service, browser, and device. Verify the compact horizontal profile card (id "PubkyAuthProfile") shows the correct identity and name, with Cancel and Authorize reachable without overlap. + Go back to the introduction and turn off PubkyAuthShareUsdt. Continue to authorization; verify Authorize remains enabled, then cancel + Open the auth fixture with only x-bitkit-claim=paykit-access-v1; verify Paykit permissions and Details are shown without an empty Payment Details to Share section, then cancel. + Turn USDT off in Settings, then open x-bitkit-claim=watch-only-account-v1.usdt-address-v1. Verify the introduction and authorization show Bitcoin only: no USDT sharing switch, address, privacy text or enable prompt. Cancel before Authorize. + With USDT still off, repeat with x-bitkit-claim=paykit-access-v1.watch-only-account-v1.usdt-address-v1. Verify Bitcoin and Paykit permissions remain available without any USDT content or enable prompt, then cancel. + With USDT still off, open x-bitkit-claim=paykit-access-v1.usdt-address-v1. Verify Paykit permissions appear without a receiving-details introduction or USDT content. Go back and verify the request closes without showing a Bitcoin introduction. + With USDT still off, open x-bitkit-claim=usdt-address-v1. Verify the request is unavailable and authorization cannot be submitted; no enable prompt or successful setup is shown. Cancel and verify Settings still shows USDT off. + Enable USDT in Settings and reopen the combined request. Verify the optional USDT switch and selected-address privacy disclosure return on the introduction. Turn sharing off and verify Bitcoin approval remains available, then cancel. + With an owned wallet restored before its funded USDT balance has loaded, open a combined Bitcoin and USDT authorization. Let the balance refresh finish while the sheet remains open. Verify the optional USDT sharing choice appears off, its address finishes loading and Bitcoin approval remains available. Turn sharing on, then off, and verify it stays off. Cancel before Authorize. + Repeat the delayed funded-balance refresh with a USDT-only authorization. Verify receiving-details consent becomes available once the address loads; continue to the permission review and cancel before Authorize. + + diff --git a/journeys/send/lightning-fee-review.xml b/journeys/send/lightning-fee-review.xml index 44c92cf77d..68b62560bc 100644 --- a/journeys/send/lightning-fee-review.xml +++ b/journeys/send/lightning-fee-review.xml @@ -15,5 +15,9 @@ On the sending wallet, open the received payment request and tap Pay if it has not opened automatically Open Show Details and verify confirmation shows 1,000 sats, Spending, the receiving contact and a numeric estimated fee Dismiss the send review without swiping to pay and verify the request remains unpaid + On the receiving wallet, enable USDT sharing and publish a fresh 10,000-sat fixed Lightning invoice for the sending contact + On the sending wallet, pay that contact and select Spending. Verify the amount is 10,000 sats and keypad or Max cannot change it + Switch to Savings or USDT and enter a different amount, then switch back to Spending. Verify the invoice amount returns to 10,000 sats + Continue to review and verify the amount is 10,000 sats, then cancel without paying diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 5c590f8dc9..b990d4e94c 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -7,23 +7,25 @@ Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked as identities + With no USDT funds or pending transfers, decline the USDT introduction or disable USDT in Settings, General, Payments Open the drawer menu and tap Subscriptions - Verify the Subscriptions screen (testTag "SubscriptionsScreen") appears with the Overview tab (testTag "Tab-overview") selected - Tap Create (testTag "SubscriptionCreate") - Verify the create sheet (testTag "CreateSubscription") appears with Choose Recipient disabled - Tap the amount pencil, enter "5000" on the keypad, and tap Continue - Verify the amount reads 5,000 sats - Type "Journey Sub" into the subscription name field (testTag "SubscriptionName") - Enter 600 ASCII letters in the description field (testTag "SubscriptionDescription") - Verify the frequency selector has "Monthly" selected - Tap Choose Recipient (testTag "SubscriptionChooseRecipient"), now enabled - Verify the recipient step lists the linked contact under CONTACTS and that Propose Subscription (testTag "SubscriptionPropose") is disabled + Verify the Subscriptions screen (id "SubscriptionsScreen") appears with the Overview tab (id "Tab-overview") selected + Tap Create (id "SubscriptionCreate") + Verify the create sheet (id "CreateSubscription") appears with Choose Recipient disabled + Tap the amount pencil (id "SubscriptionEditAmount") and verify USD is not offered while USDT is disabled + Tap the Bitcoin/fiat unit control (id "PaymentRequestAmountUnit") twice and verify both input units remain available for the BTC denomination. Select Bitcoin units, enter "5000" on the keypad, and tap Continue (id "PaymentRequestAmountContinue") + Verify the amount reads 5,000 sats and the accepted methods show only enabled Savings or Spending options, with no USDT + Type "Journey Sub" into the subscription name field (id "SubscriptionName") + Enter 600 ASCII letters in the description field (id "SubscriptionDescription") + Verify the frequency selector has "Monthly" selected (id "Tab-month") + Tap Choose Recipient (id "SubscriptionChooseRecipient"), now enabled + Verify the recipient step lists the linked contact under CONTACTS and that Propose Subscription (id "SubscriptionPropose") is disabled Tap the linked contact row and verify it shows the selected checkmark - Tap Propose Subscription (testTag "SubscriptionPropose") + Tap Propose Subscription (id "SubscriptionPropose") Verify an error asks to shorten the subscription content and no sent confirmation appears Tap Back to return to the subscription details and replace the description with "Monthly support" Tap Choose Recipient, select the linked contact if needed, and tap Propose Subscription again - Verify the sent confirmation (testTag "SubscriptionProposalSent") shows headline "Sent Proposal", the recipient contact, and the subscription name with 5,000 sats + Verify the sent confirmation (id "SubscriptionProposalSent") shows headline "Sent Proposal", the recipient contact, and the subscription name with 5,000 sats Tap OK Verify a row for "Journey Sub" appears in the CREATED section with subtitle "Proposal sent", not "Proposal queued" diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 8d0c084c6f..0dd55c7f6b 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -7,22 +7,33 @@ confirmation opens directly. Both paths end on Close. A hardware wallet still shows the send confirmation for the first payment. Run this on the payer instance immediately after the creator sends the proposal. + + The handoff's "Automatically pay this subscription" toggle is unbuilt on both platforms. + + The swipe knob is USDT teal when USDT is the only accepted payment method, otherwise brand orange. Launch the E2E Bitkit app with Paykit UI enabled on the payer instance, with the creator saved as a linked contact, on-chain savings above 5,000 sats plus the network fee, and no usable Lightning balance, so a first payment is sent on-chain Have the creator instance send the "Journey Sub" proposal as described in create-and-propose.xml Open the drawer menu and tap Subscriptions Verify a row for "Journey Sub" appears under the PROPOSALS section - Tap the "Journey Sub" row (testTag "SubscriptionRow-<paymentRequestId>") + Tap the "Journey Sub" row (id "SubscriptionRow-<paymentRequestId>") Verify the Review and Subscribe sheet appears with 5,000 sats and the subscription card showing its name and cadence Verify the swipe control reads "Swipe To Subscribe", whether or not the first period is due on acceptance + Verify the swipe knob is USDT teal when USDT is the only accepted payment method, otherwise brand orange Swipe the confirm control to the end - If the first period was due on acceptance: verify the sheet stays expanded, without closing, exposing Home or the subscriptions list, and sliding back in, and the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears + If the first period was due on acceptance: verify the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending Verify the sheet dismisses and "Journey Sub" moves from PROPOSALS into the ACTIVE section Tap the "Journey Sub" row and verify the detail shows STATUS "Active" and a RENEWS date one month ahead, with no year Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent. + Have the creator send a second proposal for 2 USD monthly, accepting only USDT, with its first period due on acceptance; open its Review and Subscribe sheet on the payer with USDT enabled and enough USDT for 2 USDT plus the payment fee + Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe + Verify the Amount screen keeps $2 locked and USDT as its only payment source; no Bitcoin amount, Savings source or editable amount is offered + Tap Continue and verify the USDT review shows 2 USDT, the creator's address and the fee; do not swipe to pay + Close the send sheet, reopen this subscription's unpaid first period from Payments, and verify it still offers the locked 2 USDT payment; cancel without sending + Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. diff --git a/journeys/usdt-bridge-amount-errors.xml b/journeys/usdt-bridge-amount-errors.xml new file mode 100644 index 0000000000..f365fbc79e --- /dev/null +++ b/journeys/usdt-bridge-amount-errors.xml @@ -0,0 +1,9 @@ + + Use an onboarded USDT wallet and a gateway fixture that rejects outbound quotes and inbound estimates with xchain_amount_too_small. No payment is submitted. The error describes live bridge costs rather than inventing a fixed network minimum. + + Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. + Open USDT Send, select Tron and enter a valid external Tron address. Enter 3.5 USDT and continue. Verify the amount screen remains visible, payment review does not open, and the error says Amount too small to cover bridge costs. Try a larger amount. + Return to USDT Receive, select Tron and enter an expected deposit amount of 3.5 USDT. Continue. Verify the same actionable error is shown and no deposit QR is presented. Verify the error does not claim that the provider's advertised minimum guarantees acceptance. + Change the fixture to provide a valid quote or estimate, increase the amount and continue. Verify the error clears and the flow advances. Stop before approving a payment or sharing a deposit request. + + diff --git a/journeys/usdt-bridge-details.xml b/journeys/usdt-bridge-details.xml new file mode 100644 index 0000000000..07c9d93c63 --- /dev/null +++ b/journeys/usdt-bridge-details.xml @@ -0,0 +1,19 @@ + + Use an isolated provider fixture with a pending Arbitrum receipt, an outgoing Orchestra transfer that is delivering, and supported and unsupported provider deposits. No funded wallet, payment submission, support submission or real refund is required. Native Android and iOS confirmation dialogs differ in platform styling. + + Open the pending incoming USDT activity. Verify the Receiving USDT title, incoming amount, teal down arrow, yellow Pending status, receiving network, date, time, payment and available fee. + Tap Details (id "UsdtActivityDetails"). Verify the real wallet transaction and available addresses are shown without inventing a provider deposit reference or refund eligibility. + Open Cross-Network Deposits (id "UsdtDepositHistory"). Verify the Provider Status note says the spendable balance updates when USDT arrives on Arbitrum, and each row shows its source network, provider status and available amount. + Open a supported deposit that needs attention (id "UsdtDepositRow-{depositId}"). Verify the summary appears first with the provider status and receiving network. Do not invent a deposit date or individual fee when the provider does not supply one. Tap Details (id "UsdtDepositDetails"). Verify its own source transaction and deposit reference are shown. Verify the refund instructions and address placeholder name that deposit's source network. + Enter an owned test refund address (id "UsdtRefundAddress") slowly across two background refreshes. Verify the field stays enabled and focused, the keyboard remains open, and every typed character is retained while the provider status updates. Then tap Request refund (id "UsdtDepositRefund"). Verify Confirm Refund shows the exact deposit amount with up to six decimals, network, full refund address and refund fee note. In a comma locale, a 3.5 USDT deposit must display 3,50 USDT. Hidden balances must remain masked, and an unknown amount must remain a dash. Cancel without submitting. + Tap Hide details (id "UsdtDepositDetails"), then Details again. Verify the provider status remains separate from wallet activity and the entered refund address is retained. + Open completed, refunded and refund-in-progress deposit fixtures. Verify Request Refund is unavailable and the current provider status and available refund transaction are shown. An unsupported asset or network must show support guidance instead of a refund form. + While Cross-Network Deposits is open, inject a confirmed incoming USDT transfer through the isolated fixture. Verify no received sheet interrupts deposit details or refund review. Return to the USDT wallet and verify the received celebration appears once after any active sheet is dismissed. + Open the outgoing delivering USDT activity. Verify the Delivering USDT title, outgoing amount, teal up arrow, yellow Delivering status and actual receiving network. + Tap Details (id "UsdtActivityDetails"). Verify the source transaction, sender and recipient addresses, sender and recipient networks, Orchestra provider and bridge reference. Verify Open Block Explorer (id "UsdtActivityExplorer") and Hide details remain reachable. + For a delayed, failed, refunding, refunded or support-needed bridge fixture, verify Contact Support (id "UsdtContactSupport") and Copy Details (id "UsdtCopyDetails") remain reachable from its details without submitting either action. + Return to the USDT activity list. Verify Pending uses a yellow hourglass and 16% yellow circle, Failed uses a red x-circle and 16% red circle, and Transferring uses the teal transfer arrows. Sent and Received remain teal. + Open USDT Receive and inject one confirmed incoming USDT transfer through the isolated fixture. Verify Receive is replaced by one received celebration. Dismiss it and verify the same transfer is not celebrated again; a different subsequent transfer must still be shown. + Open USDT Receive, then Network. Verify Cross-Network Deposits (id "UsdtDepositHistory") remains reachable before any Arbitrum receipt exists. + + diff --git a/journeys/usdt-bridge-support.xml b/journeys/usdt-bridge-support.xml new file mode 100644 index 0000000000..7d090b692b --- /dev/null +++ b/journeys/usdt-bridge-support.xml @@ -0,0 +1,16 @@ + + Use an onboarded wallet with an outbound cross-network transfer and an Orchestra deposit in its history. Existing history or a provider fixture is sufficient; do not submit a payment or support request. + + Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. + Open USDT from Home, then open a cross-network transfer in Activity. + Tap Details (id "UsdtActivityDetails"), then verify the source transaction is displayed. For an Orchestra transfer, verify the bridge reference is also displayed. + Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard contains the bridge, networks, status and available transaction references. + Tap Contact Support (id "UsdtContactSupport"). Verify the report form opens with those details prefilled and remains unsent. Return without submitting. + Open USDT Receive, then Network, scroll to Cross-Network Deposits (id "UsdtDepositHistory") and open it. Verify rows show the source network and provider status, then open a deposit. + For a deposit whose conversion order is funded, confirming, swapping, bridging or delivering without an error, verify Pending is shown. Refresh after completion and verify Confirmed. An active order with an explicit error, or an unknown status, must show Needs attention. + For a refunded deposit, verify the detail shows Refunded, even when an earlier error code is retained. Verify the transaction is labeled Refund transaction in the detail. Verify no pending-refund notice remains after refreshing the completed refund. + For a refund in progress, verify Refunding is shown for the provider's pending acknowledgment. A refund requiring operator intervention must still show Needs attention. + Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard includes the deposit reference, source network and source transaction, plus the current status and any destination or refund transaction. + Tap Contact Support (id "UsdtContactSupport"). Verify the report form opens with the deposit details prefilled. Return without submitting or requesting a refund. + + diff --git a/journeys/usdt-opt-in.xml b/journeys/usdt-opt-in.xml new file mode 100644 index 0000000000..c4bea8ddb9 --- /dev/null +++ b/journeys/usdt-opt-in.xml @@ -0,0 +1,16 @@ + + Use a fresh onboarded wallet with USDT services configured. The wallet has no USDT balance or pending transfers. Use owned payment links and existing requests; no payment is submitted. The explicit-link enable prompt is a swipe-dismissable sheet on iOS; Android uses an alert with Cancel and Enable actions and blocks outside/Back dismissal. + + On Home, verify the New: USDT sheet (id "UsdtIntro") shows the Tether coin, DOLLARS? YOUR CALL heading, No Thanks and Enable USDT. + Swipe the intro sheet down. Verify Home shows Savings and Spending without a USDT tile. Open Receive and verify there is no USDT tab, then close it. + Terminate and relaunch the app. Verify the USDT intro returns. Tap No Thanks (id "UsdtIntroDecline"), relaunch again, and verify the intro does not return. + While USDT remains off on this fresh wallet, use the local gateway request log to verify Home and wallet backups do not request the wallet's USDT balance or history. Bitcoin and Lightning continue normally. + Open Settings, General, Payments. Verify USDT (Tether) (id "UsdtSettingsToggle") is off. Enable it, return Home, and verify the USDT tile appears. Open Receive and verify the USDT tab appears. + Open USDT Send and its network pill. Verify the USDT Network picker (id "UsdtNetworkPicker") uses rounded rows with Tether icons, a teal selection check, the recipient-network explanation and Tether coin illustration. Choose another supported network and verify the pill updates while retaining its network icon. + Return to Settings and turn USDT off while the wallet is empty and all transfers are complete. Verify the USDT tile, receive tab and new Paykit receiving option disappear. Existing payment history remains stored. + With completed USDT history and a zero balance, disable USDT and relaunch. While the balance loads or its refresh fails, history may keep the USDT tile visible, but the Settings switch stays off and can be enabled. Verify Receive and optional address sharing still omit USDT. Enable it and verify the switch stays on and cannot be disabled until the zero balance is confirmed. + Open an owned USDT payment link while USDT is off. Verify the Enable USDT prompt (id "UsdtEnablePrompt"). Decline and verify USDT stays off, the flow returns to the previous screen, and no payment spinner or failure screen remains. Where interactive dismissal is available, open the link again and dismiss the prompt without tapping Cancel; verify the same quiet return with no USDT wallet or Send/Receive controls left open. Open the link again, enable USDT (id "UsdtEnablePromptContinue"), and verify the recipient and exact amount continue into the payment flow. Cancel without paying. + With a funded wallet or an unresolved transfer, open Settings. Verify USDT is visibly on, greyed out and cannot be toggled. Verify the wallet, history and pending payment remain accessible. + Restore an owned funded wallet whose USDT preference is absent or off. Verify USDT is enabled and its Settings switch is on and greyed out. Open Receive from Home and verify it keeps the normal default tab (Auto when available, otherwise Savings). Select the available USDT tab and verify its receiving address appears without an enable prompt. No payment is submitted. + + diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml new file mode 100644 index 0000000000..85c2151c5d --- /dev/null +++ b/journeys/usdt-payment-links-and-scans.xml @@ -0,0 +1,21 @@ + + Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. + + Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. + Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. Verify no payment occurs without approval. + Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify the USDT amount screen opens with the copied recipient and amount. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. + Cancel the send and remain on the USDT wallet without returning Home, then reopen the identical URI. Verify the amount or review screen opens again. Cancel back to the USDT wallet and open a different owned recipient and amount, verifying only the new values appear. + Close the send sheet, open Settings from the drawer, then go back. Verify the dismissed link does not reopen its Amount screen. Explicitly reopen the same link and verify a new send flow is available, then cancel without paying. + While a USDT contact amount screen, contact review or direct-send review is open, copy another owned bare USDT address and verify no automatic clipboard prompt appears. Repeat with a USDT payment URI. Open another USDT link, then scan another owned USDT address. Verify the active recipient, amount and contact or request are preserved, with no new send flow or invalid-QR error. Where PIN is enabled, repeat while its approval screen is visible, then cancel without paying. + Open USDT from Home, open a USDT link, then open a second link with another owned recipient and amount. Verify the first amount and recipient remain. Cancel without paying. + Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount. + Cancel the send. From the main scanner, paste or import a QR containing the same amount-bearing URI. Verify it opens the USDT send flow with the same recipient and amount. + Cancel the send. From the ordinary Send options, paste the URI. Verify it opens the USDT send flow. Cancel, choose Enter Manually, enter the same URI and continue. Verify the USDT recipient and exact amount are preserved. Repeat with a wrong-chain URI and in hardware-wallet Send; verify neither opens a USDT payment. In hardware-wallet manual entry, verify the Bitcoin-address-required warning appears and Continue stays disabled. Replace the URI with a valid Bitcoin address and verify Continue becomes available. Cancel, then open Send again from the USDT wallet and verify the previous recipient and amount are cleared. + Open or scan variants with the wrong chain, wrong token, duplicate amount and malformed recipient. Verify an error and no payable review. Check an ordinary Bitcoin or Lightning invoice still opens its existing send flow. + On iOS, open a USDT link while Lightning is stopped but the wallet is ready. Verify it routes without starting Lightning. Open two different USDT links while the main scanner sheet is dismissing; verify an older delayed link cannot replace the review that opened first. + Enter this wallet’s own Arbitrum address in USDT Send, enter an amount and continue. Verify the error explicitly says you cannot send USDT to your own address, and no payment is submitted. Repeat with MAX. On Android, enter this wallet’s own bare Arbitrum address through Home Send → Enter Manually and verify the self-send message appears while Continue stays disabled. + Enter the entire available USDT balance and continue. Verify the fee-collection error explains that the transaction fee could not be paid in USDT and suggests a smaller amount. Reduce the amount, reach review and close without signing. + With an empty wallet and no pending transfers, turn USDT off in Settings. Copy an owned bare USDT address, return to the app, and verify no automatic payment or enable prompt. Repeat with an amount-bearing USDT payment URI. Copy a valid Bitcoin address and a Lightning invoice in turn; verify their normal clipboard prompts still appear and cancel without paying. + With USDT off, explicitly open the same USDT URI and verify the designed Enable USDT modal appears. Decline and verify a quiet return with no stuck spinner. Scan its QR, enable USDT, and verify the original recipient and exact amount resume. Cancel without paying. + + diff --git a/journeys/usdt-payment-restoration.xml b/journeys/usdt-payment-restoration.xml new file mode 100644 index 0000000000..caf3c93aa2 --- /dev/null +++ b/journeys/usdt-payment-restoration.xml @@ -0,0 +1,10 @@ + + Use the local USDT test environment, an onboarded wallet with test funds and another owned wallet's Arbitrum address. Submit only a small local test payment. Background before terminating the app process so saved screen state can be restored. Android restores an unpaid amount draft; iOS may return to the wallet. + + Open an owned USDT payment link with an amount, then background, terminate and reopen the app before submitting. On Android, verify the same unpaid amount and recipient remain; on iOS, either the original draft or the wallet is acceptable. No payment occurs automatically. + Open the owned link if necessary, review the amount and fee, and submit the local test payment. While Payment submitted or USDT Sent is showing, background, terminate and reopen the app. Verify the wallet opens with no send sheet or prefilled paid amount. Verify the original transfer remains in history and pending tracking continues. + On Android, repeat with a local test payment and change the device font size while submission or the result is visible. Verify the recreated screen still shows the payment progress or result, including its Details action when available. In a separate local payment, background and terminate during submission, then reopen. Verify the wallet opens without an editable send draft and the original operation continues through pending tracking. + Tap Send from the USDT wallet. Verify recipient entry and the amount are empty. Cancel without paying. + Reopen the original payment link explicitly. Verify it opens a new send flow with that link's recipient and amount; cancel. Open a link for a different owned recipient and amount and verify only those new values appear. Cancel without paying. + + diff --git a/journeys/usdt-receive-amount-and-units.xml b/journeys/usdt-receive-amount-and-units.xml new file mode 100644 index 0000000000..282e7436a5 --- /dev/null +++ b/journeys/usdt-receive-amount-and-units.xml @@ -0,0 +1,15 @@ + + Use an onboarded wallet with USDT configured and a fresh BTC/USD rate. No payment is submitted. Cross-network estimates require a live enabled provider and are covered by the manual USDT checks. + + Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. + Open USDT from Home. Verify the USDT balance, teal Tether icon and existing activity rows. + Open Receive. Verify Receive Dollars, the USDT tab and Network: Arbitrum One on the QR screen. + Tap Edit, enter 5 and tap Back. Verify the QR has no uint256 amount. + Tap Edit. Enter 3.5. Verify the dollar amount and BTC equivalent are displayed, there is no input-unit toggle, the keypad follows the divider, and Continue stays at the bottom without overlap. + Continue to the QR. Verify it requests the Arbitrum USDT token, the wallet address and uint256=3500000, never Bitcoin. + Tap Edit, enter a different amount and tap Back. Verify the QR still requests uint256=3500000. + Tap Copy on the QR screen and verify the clipboard contains the exact payment URI including uint256=3500000. Open Share and verify the shared text includes Network: Arbitrum One and the exact payment URI. Cancel sharing. Open Show Details. Verify Network: Arbitrum One, the same Arbitrum address and teal Copy control. Verify Share includes the same network and payment URI, while Copy contains only the address. Open Network and verify Arbitrum is marked Default and all enabled bridge networks remain available, with circular USDT icons. Return to the QR. + Select Savings. Verify Receive Bitcoin and a Bitcoin QR. Select USDT and verify Receive Dollars and the Arbitrum USDT QR. + Close Receive and return Home. Open a USDT activity row, then Show All. Verify USDT details and mixed activity remain reachable. + + diff --git a/journeys/usdt-send-review-and-details.xml b/journeys/usdt-send-review-and-details.xml new file mode 100644 index 0000000000..2209f76bba --- /dev/null +++ b/journeys/usdt-send-review-and-details.xml @@ -0,0 +1,17 @@ + + Use an onboarded wallet with USDT, a fresh BTC/USD rate, an owned recipient and existing sent/received activity. Quotes require the service with MAX support. No payment is submitted. + + Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. + Open USDT Send and enter the owned recipient. On Amount, verify the BTC equivalent above the dollar amount, 32-point gap before Available balance, USDT badge, MAX and network pill. Enter a six-decimal amount and verify no precision is lost. With a large available balance and BNB Smart Chain selected, verify both values remain fully readable; the controls may wrap onto a second row. + Open the network pill and verify the full USDT Network picker, Tether rows and selected teal check. Select another EVM network. Verify the amount and valid recipient remain. Change to Solana with an EVM recipient and verify recipient entry is required. Enter an owned plain Solana address and verify the previous amount remains. Return to Arbitrum, enter the owned EVM address and verify the amount still remains. + From recipient entry with another network selected, paste an explicit Arbitrum USDT URI. Verify the selected network becomes Arbitrum and the link amount is retained. A plain 0x address preserves the selected network. + Tap MAX. Verify it fills a positive amount below the balance and remains on Amount. Continue and verify the payment plus maximum fee fits within the balance. Back out without swiping. + Enter a smaller amount and continue. Verify the collapsed review shows the amount, coin illustration, Show Details and teal Swipe To Pay. Show Details reveals Pay from, Network, Payment, Fee and Address, with the fee note in the scrollable details. Displayed amounts round to cents; nonzero values below one cent show <0.01. Hide Details restores the illustration. Cancel without paying. + Prepare an ordinary amount and note the payment, fee and maximum total in Details. Go back to Amount and immediately continue without editing; verify the same review values return. Go back, change the amount and continue; verify the payment reflects the new amount. Go back, wait until the quote expires (at most two minutes) and continue; verify a new quote is obtained or an error leaves the amount editable, rather than displaying expired fees. Cancel without swiping. + Prepare a cross-network payment and continue. Verify You send labels the entered amount and Recipient gets appears directly beneath it, with an approximation marker for Orchestra. Verify the recipient amount follows the quote rather than assuming it equals the sent amount. Without scrolling, verify Show Details (id "UsdtReviewDetails"), Maximum total debit (id "UsdtMaximumTotal") and its value are fully visible above Swipe To Pay. Show Details and verify Hide Details remains visible above the maximum, Payment is labelled You send, and the fee note can be read by scrolling without overlapping the pinned total. For Orchestra, also verify the deducted bridge cost is fully readable when scrolled into view. Cancel without paying. + Using the local quote fixture, prepare a 5 USDT bridge with 2 USDT received and a small network fee. Try to proceed and verify the fee-over-half-value warning appears before payment authentication. Cancel without paying. + Open an existing sent USDT activity. Verify the heading is Sent USDT, the signed amount includes the actual fee, and summary rows show Status/Network, Date/Time and Payment/Fee with teal icons. Open Details and verify transaction ID, known sender, recipient and source/destination networks; Open Block Explorer stays at the bottom. + Open an existing received activity. Verify Received USDT and a positive amount. Verify Details shows the verified sender when available and does not invent one for older unknown records. Check pending, failed and transferring rows use their matching icons, and transferring shows its destination. + Prepare a contact payment that accepts multiple payment methods. Open the payment-source button and verify the balance menu shows Savings, Spending, any paired hardware wallet and USDT. Unsupported methods are disabled. Select a supported method and verify its label, available balance and payable amount update. Cancel without paying. + + diff --git a/journeys/usdt-wallet-navigation.xml b/journeys/usdt-wallet-navigation.xml new file mode 100644 index 0000000000..663acd4b3d --- /dev/null +++ b/journeys/usdt-wallet-navigation.xml @@ -0,0 +1,14 @@ + + Use an onboarded wallet in an E2E build with USDT RPC and bundler URLs configured. No chain funding or live provider is required. A network error may be visible when services are offline; no payment is submitted. + + Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. + On Home, verify the Savings/Spending and USDT balance dividers align vertically, including any hardware wallet rows. With an odd number of paired hardware wallets, verify USDT fills the last row’s empty cell; with an even number, it starts the next row. + Verify USDT balances and activity amounts show at most two decimals; a nonzero amount below one cent shows <0.01. Open USDT from Home and verify the wallet screen uses the same balance, action buttons and activity layout as the Bitcoin wallet, with its Tether coin illustration anchored at the upper right. + Open Receive. Verify the network is Arbitrum One. Open details, verify the address starts with 0x, and verify Details Copy contains only the address. Return to the QR and verify Copy contains the full USDT payment URI including the network and token. On Android, system Back from Details returns to the USDT QR; Back from the QR closes Receive without switching to Savings. + Close Receive, open Send and manual entry. Enter an invalid Bitcoin address and verify the address error prevents continuing. + Replace it with an owned valid EVM address. Continue to Amount, enter six decimal places and try a seventh; verify the amount remains limited to six. Close before requesting a quote. + Set an amount and note in Bitcoin Receive, then close it. Open a fresh USDT Receive sheet and switch to Savings and Spending. Verify Savings shows a fresh Bitcoin QR with no previous amount or note. Spending shows a fresh Lightning QR when receive liquidity is available; an unfunded wallet shows the Spending receive setup instead. Verify hardware-wallet receive remains available when paired. + Return Home and open Bitcoin Receive. Verify the Bitcoin address, network and amount controls remain separate from USDT. + With saved USDT activity, open All Activity. Search for USDT and usdt and verify both show USDT rows. Search for u, us or t and verify a USDT row appears only if its recipient or transaction ID contains the query. Verify a matching recipient or transaction-ID fragment still finds the row, and clearing search restores the list. + + diff --git a/settings.gradle.kts b/settings.gradle.kts index b1aede9e15..ab918f74e7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,7 +37,10 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { mavenLocal { - content { excludeModule("com.synonym", "paykit-android") } + content { + excludeModule("com.synonym", "paykit-android") + excludeModule("com.synonym", "bitkit-core-android") + } } google() mavenCentral()