View Issue Details

IDProjectCategoryView StatusLast Update
0012070Talerwallet (Android App)public2026-11-11 01:28
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerReviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeveritymajorReproducibilityhave not tried
Status newResolutionopen 
Summary0012070: Android: a payto:// link's "Check fees" button makes the deposit immediately (no fees, no confirmation); payto://x-taler-bank is rejected
DescriptionOpening a payto:// link (or scanning a payto QR code) in the Android wallet leads to a screen titled "Send" with an amount field and a button labelled "Check fees". That button does not check fees: it navigates to the Deposit destination, whose LaunchedEffect immediately calls createDepositGroup. The user never sees the fee, the amount the account will receive, or a confirmation step, and the deposit cannot be undone. In addition only payto://iban is accepted; a payto://x-taler-bank link (the only wire method of the demo and of regional-currency exchanges) is rejected as "Not a valid Taler link".

What the user sees (screenshots attached, demo backend, wallet holding KUDOS:20):
- before-android-1.png: payto://iban/DE90…?receiver-name=Alice%20Example -> "Send", amount KUDOS 1.00, "Check fees".
- before-android-2.png: after tapping "Check fees". Here the demo exchange refuses IBAN, so the page is an error; before-android-logcat.txt shows that the app sent createDepositGroup straight away (no checkDeposit). With an exchange that accepts IBAN this would have been a real deposit made by a button called "Check fees".
- before-android-3.png: payto://x-taler-bank/bank.demo.taler.net/<account> -> "Not a valid Taler link".
- before-web-1..3.png (web wallet, reference): the same x-taler-bank link opens the deposit form with that destination; "Review deposit" shows "Bank account receives", "Wallet spends", coin / wire / renewal fees; then "Confirm deposit".
- before-ios-1.png: iOS does not handle payto: at all (openurl fails with LSApplicationWorkspaceErrorDomain 115).

Code (taler-android origin/master 4c483527, wallet/src/main/java/net/taler/wallet/):
- HandleUriScreen.kt:58-61: payto:// -> WalletDestination.PaytoUri
- deposit/PayToUriScreen.kt:81, 96-101: isSupportedPayToUri else uri_invalid; deposit/DepositManager.kt:61-66: authority must be "iban"
- deposit/PayToUriScreen.kt:145-163: button send_deposit_check_fees_button ("Check fees") -> WalletDestination.Deposit(amount, receiverName, IBAN)
- deposit/DepositScreen.kt:60-66: LaunchedEffect(Unit) { if (presetAmount != null && receiverName != null && iban != null) depositManager.makeDeposit(...) }
- deposit/DepositManager.kt:149-167: makeDeposit = createDepositGroup without checkDeposit
The regular deposit path (DepositScreen.kt:140-156, DepositAmountComposable.kt) already does everything that is missing: getMaxDepositAmount, checkDeposit with fee and net amount, "Make deposit".

Patch (attached, android-payto-deposit-review.patch, applies to origin/master):
- WalletDestination.Deposit carries the payto URI instead of amount/name/IBAN.
- DepositScreen: a payto destination is selected as a transient KnownBankAccountInfo (label = receiver-name) via depositManager.selectAccount(), so the normal amount screen with fee check and "Make deposit" is shown; nothing is deposited until the user confirms.
- PayToUriScreen hands over to that screen; its own amount step and the "Check fees" button are removed.
- isSupportedPayToUri accepts every type PaytoUri.parse() knows (iban, x-taler-bank, cyclos, bitcoin).
The string send_deposit_check_fees_button becomes unused (kept to avoid touching the translations).

Verification: built :wallet:assembleFdroidSim; after-android-1.png: the x-taler-bank link opens "Deposit to bank account" with the destination, "Available for transfer: ク20.00", amount, "Make deposit"; after-android-logcat.txt shows getMaxDepositAmount and checkDeposit, no createDepositGroup. after-android-2-iban.png: the IBAN link shows "Available for transfer: ク0.00" / "Insufficient balance" and a disabled button; no deposit is attempted.

Assisted by LLM
Steps To Reproduce1. Fund the wallet with KUDOS from https://bank.demo.taler.net/ (exchange.demo.taler.net).
2. Open payto://iban/DE90517954249548141481?receiver-name=Alice%20Example (adb shell am start -a android.intent.action.VIEW -d '<uri>').
3. Enter 1, tap "Check fees": the app calls createDepositGroup at once (logcat), no fee or confirmation screen.
4. Open payto://x-taler-bank/bank.demo.taler.net/<account>?receiver-name=X: "Not a valid Taler link".
TagsNo tags attached.
Attached Files
before-android-1.png (75,852 bytes)   
before-android-1.png (75,852 bytes)   
before-android-2.png (72,734 bytes)   
before-android-2.png (72,734 bytes)   
before-android-3.png (23,874 bytes)   
before-android-3.png (23,874 bytes)   
before-android-logcat.txt (244 bytes)   
11-11 02:01:43.664  2985  3154 D BackendManager:   "operation": "createDepositGroup",
11-11 02:01:43.670  2985  3151 D taler-wallet-embedded: 2026-11-11T01:01:43.669Z wallet.ts INFO finished wallet core request createDepositGroup with error: {
before-android-logcat.txt (244 bytes)   
before-web-1.png (60,862 bytes)   
before-web-1.png (60,862 bytes)   
before-web-2.png (107,259 bytes)   
before-web-2.png (107,259 bytes)   
before-web-3.png (127,534 bytes)   
before-web-3.png (127,534 bytes)   
before-ios-1.png (234,015 bytes)   
before-ios-1.png (234,015 bytes)   
after-android-1.png (117,040 bytes)   
after-android-1.png (117,040 bytes)   
after-android-2-iban.png (111,035 bytes)   
after-android-2-iban.png (111,035 bytes)   
after-android-logcat.txt (254 bytes)   
11-11 02:06:02.536  5359  5417 D BackendManager:   "operation": "getMaxDepositAmount",
11-11 02:06:13.057  5359  5418 D BackendManager:   "operation": "checkDeposit",
11-11 02:06:13.070  5359  5416 D BackendManager:   "operation": "getMaxDepositAmount",
after-android-logcat.txt (254 bytes)   
android-payto-deposit-review.patch (11,439 bytes)   
diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
index 3d4c2eaa..bf1df1fb 100644
--- a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
+++ b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
@@ -80,11 +80,8 @@ sealed interface WalletDestination {
 
     @Serializable
     data class Deposit(
-        val amount: String? = null,
-        val receiverName: String? = null,
-        val receiverPostalCode: String? = null,
-        val receiverTown: String? = null,
-        val IBAN: String? = null
+        /** Destination given by a payto:// link; the user still picks the amount and confirms. */
+        val paytoUri: String? = null,
     ) : WalletDestination
 
     @Serializable
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
index 9bbf9d22..aa7ff2ce 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
@@ -30,6 +30,7 @@ import net.taler.common.Amount
 import net.taler.common.CurrencySpecification
 import net.taler.wallet.main.TAG
 import net.taler.wallet.accounts.KnownBankAccountInfo
+import net.taler.wallet.accounts.PaytoUri
 import net.taler.wallet.accounts.PaytoUriBitcoin
 import net.taler.wallet.accounts.PaytoUriIban
 import net.taler.wallet.accounts.PaytoUriTalerBank
@@ -42,7 +43,6 @@ import net.taler.wallet.balances.BalanceManager
 import net.taler.wallet.balances.ScopeInfo
 import net.taler.wallet.exchanges.ExchangeManager
 import org.json.JSONObject
-import androidx.core.net.toUri
 
 class DepositManager(
     private val api: WalletBackendApi,
@@ -58,12 +58,8 @@ class DepositManager(
         exchangeManager.getCachedSpecForCurrency(currency)
             ?: exchangeManager.getSpecForScopeInfo(ScopeInfo.Global(currency))
 
-    fun isSupportedPayToUri(uriString: String): Boolean {
-        if (!uriString.startsWith("payto://")) return false
-        val u = uriString.toUri()
-        if (!u.authority.equals("iban", ignoreCase = true)) return false
-        return u.pathSegments.isNotEmpty()
-    }
+    fun isSupportedPayToUri(uriString: String): Boolean =
+        PaytoUri.parse(uriString) != null
 
     @UiThread
     fun selectAccount(account: KnownBankAccountInfo) = scope.launch {
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt
index 605298a5..63f78718 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt
@@ -28,11 +28,12 @@ import androidx.compose.runtime.livedata.observeAsState
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.lifecycle.map
-import net.taler.common.Amount
 import net.taler.wallet.NavigateCallback
 import net.taler.wallet.R
 import net.taler.wallet.WalletDestination
+import net.taler.wallet.accounts.KnownBankAccountInfo
 import net.taler.wallet.accounts.ListBankAccountsResult
+import net.taler.wallet.accounts.PaytoUri
 import net.taler.wallet.balances.ScopeInfo.ExchangeLegacyKeys
 import net.taler.wallet.compose.ErrorComposable
 import net.taler.wallet.compose.GlobalScaffold
@@ -51,16 +52,19 @@ fun DepositScreen(
     val accountManager = model.accountManager
     val exchangeManager = model.exchangeManager
 
-    val presetAmount = dest.amount?.let { Amount.fromJSONString(it) }
-    val receiverName = dest.receiverName
-    val receiverPostalCode = dest.receiverPostalCode
-    val receiverTown = dest.receiverTown
-    val iban = dest.IBAN
-
     LaunchedEffect(Unit) {
-        if (presetAmount != null && receiverName != null && iban != null) {
-            val paytoUri = getIbanPayto(receiverName, receiverPostalCode, receiverTown, iban)
-            depositManager.makeDeposit(presetAmount, paytoUri)
+        // A payto:// link only names the destination: continue with the amount
+        // step, which shows fees and the maximum, and deposit when the user confirms.
+        dest.paytoUri?.let { paytoUri ->
+            depositManager.selectAccount(
+                KnownBankAccountInfo(
+                    bankAccountId = "",
+                    paytoUri = paytoUri,
+                    kycCompleted = false,
+                    currencies = null,
+                    label = PaytoUri.parse(paytoUri)?.receiverName,
+                )
+            )
         }
         accountManager.listBankAccounts()
     }
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt
index 019fc840..39210fdf 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt
@@ -16,40 +16,21 @@
 
 package net.taler.wallet.deposit
 
-import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Button
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-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.runtime.LaunchedEffect
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalFocusManager
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import net.taler.common.Amount
-import net.taler.common.CurrencySpecification
-import net.taler.wallet.BottomInsetsSpacer
 import net.taler.wallet.NavigateCallback
 import net.taler.wallet.R
 import net.taler.wallet.WalletDestination
-import net.taler.wallet.compose.AmountCurrencyField
 import net.taler.wallet.compose.GlobalScaffold
+import net.taler.wallet.compose.LoadingScreen
 import net.taler.wallet.compose.TalerSurface
-import net.taler.wallet.main.AmountResult
 import net.taler.wallet.main.MainViewModel
 
 @Composable
@@ -59,11 +40,14 @@ fun PayToUriScreen(
     onNavigate: NavigateCallback,
     onNavigateBack: () -> Unit,
 ) {
-    val u = uri.toUri()
-    val receiverName = u.getQueryParameter("receiver-name")?.replace('+', ' ') ?: ""
-    val iban = u.pathSegments.lastOrNull() ?: ""
-
     val currencies = model.balanceManager.getCurrencies()
+    val supported = model.depositManager.isSupportedPayToUri(uri)
+
+    // The link names the destination; amount, fees and confirmation are
+    // handled by the regular deposit screen, as for a saved bank account.
+    if (currencies.isNotEmpty() && supported) LaunchedEffect(uri) {
+        onNavigate(WalletDestination.Deposit(paytoUri = uri), true)
+    }
 
     TalerSurface {
         GlobalScaffold(
@@ -78,21 +62,8 @@ fun PayToUriScreen(
                         text = stringResource(id = R.string.payment_balance_insufficient),
                         color = MaterialTheme.colorScheme.error,
                     )
-                } else if (model.depositManager.isSupportedPayToUri(uri)) {
-                    PayToComposable(
-                        currencies = currencies,
-                        getAmount = model::createAmount,
-                        onAmountChosen = { amount ->
-                            onNavigate(
-                                WalletDestination.Deposit(
-                                    amount = amount.toJSONString(),
-                                    receiverName = receiverName,
-                                    IBAN = iban
-                                ), true,
-                            )
-                        },
-                        getCurrencySpec = model.exchangeManager::getSpecForCurrency,
-                    )
+                } else if (supported) {
+                    LoadingScreen()
                 } else {
                     Text(
                         modifier = Modifier.padding(16.dp),
@@ -104,77 +75,3 @@ fun PayToUriScreen(
         }
     }
 }
-
-@Composable
-private fun PayToComposable(
-    currencies: List<String>,
-    getAmount: (String, String) -> AmountResult,
-    getCurrencySpec: (String) -> CurrencySpecification?,
-    onAmountChosen: (Amount) -> Unit,
-) {
-    val scrollState = rememberScrollState()
-    Column(
-        modifier = Modifier
-            .fillMaxWidth()
-            .padding(vertical = 16.dp)
-            .verticalScroll(scrollState),
-        horizontalAlignment = Alignment.CenterHorizontally,
-        verticalArrangement = Arrangement.spacedBy(16.dp),
-    ) {
-        var amount by remember { mutableStateOf(Amount.zero(currencies[0])) }
-        val currencySpec = remember(amount.currency) { getCurrencySpec(amount.currency) }
-        var amountError by rememberSaveable { mutableStateOf("") }
-
-        AmountCurrencyField(
-            modifier = Modifier
-                .padding(horizontal = 16.dp)
-                .fillMaxWidth(),
-            amount = amount.withSpec(currencySpec),
-            currencies = currencies,
-            readOnly = false,
-            onAmountChanged = { amount = it },
-            label = { Text(stringResource(R.string.amount_send)) },
-            isError = amountError.isNotBlank(),
-            supportingText = {
-                if (amountError.isNotBlank()) {
-                    Text(amountError)
-                }
-            }
-        )
-
-        val focusManager = LocalFocusManager.current
-        val errorStrInvalidAmount = stringResource(id = R.string.amount_invalid)
-        val errorStrInsufficientBalance = stringResource(id = R.string.payment_balance_insufficient)
-        Button(
-            modifier = Modifier.padding(16.dp),
-            enabled = !amount.isZero(),
-            onClick = {
-                when (val amountResult = getAmount(amount.amountStr, amount.currency)) {
-                    is AmountResult.Success -> {
-                        focusManager.clearFocus()
-                        onAmountChosen(amountResult.amount)
-                    }
-                    is AmountResult.InvalidAmount -> amountError = errorStrInvalidAmount
-                    is AmountResult.InsufficientBalance -> amountError = errorStrInsufficientBalance
-                }
-            },
-        ) {
-            Text(text = stringResource(R.string.send_deposit_check_fees_button))
-        }
-
-        BottomInsetsSpacer()
-    }
-}
-
-@Preview
-@Composable
-fun PreviewPayToComposable() {
-    Surface {
-        PayToComposable(
-            currencies = listOf("KUDOS", "TESTKUDOS", "BTCBITCOIN"),
-            getAmount = { _, _ -> AmountResult.InvalidAmount },
-            onAmountChosen = {},
-            getCurrencySpec = { null }
-        )
-    }
-}

Required notifications

Address or linkRemarkAdded byNotifiedActions

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-11 01:28bot-cgNew Issue
2026-11-11 01:28bot-cgFile Added: before-android-1.png
2026-11-11 01:28bot-cgFile Added: before-android-2.png
2026-11-11 01:28bot-cgFile Added: before-android-3.png
2026-11-11 01:28bot-cgFile Added: before-android-logcat.txt
2026-11-11 01:28bot-cgFile Added: before-web-1.png
2026-11-11 01:28bot-cgFile Added: before-web-2.png
2026-11-11 01:28bot-cgFile Added: before-web-3.png
2026-11-11 01:28bot-cgFile Added: before-ios-1.png
2026-11-11 01:28bot-cgFile Added: after-android-1.png
2026-11-11 01:28bot-cgFile Added: after-android-2-iban.png
2026-11-11 01:28bot-cgFile Added: after-android-logcat.txt
2026-11-11 01:28bot-cgFile Added: android-payto-deposit-review.patch