View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012076 | Taler | wallet (iOS App) | public | 2026-11-11 01:35 | 2026-11-11 01:36 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | major | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012076: iOS: a payment the merchant's exchange refused for legal reasons (KYC, 2186/2184) is only "Suspended" -- no reason, and Resume is hidden outside developer mode (patch attached) | ||||
| Description | When the merchant's exchange refuses a payment for legal reasons (HTTP 451 from /pay: MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED 2186, e.g. the merchant has not completed KYC, or ..._TRANSACTION_LIMIT_VIOLATION 2184), wallet-core pauses the payment instead of failing it (88b4748e "wallet: pause legally refused merchant payments", for 0011416): state suspended/submit-payment, txActions [resume, abort], the refusal stored as the transaction's error ({code: 2186, exchangeBaseUrls}). The coins stay committed; once the merchant has fixed its side, Resume completes the payment with the same funds. 0011416 was a user report from an Android wallet ("the wallet went from paying to aborted with no explanation"); only wallet-core and the WebExtension UI were updated. How the screenshots were made: no merchant reachable from our test setup gets a 451 from its exchange (the demo exchange has no KYC, and the KYC-enabled test exchange is not accepted by the test merchant). So the transaction was injected with wallet-core's own dev experiment api-response: preparePayForUriV2 -> {transactionId}, waitTransactionState -> bail, getTransactionById -> a real paid KUDOS:1 payment (taken from taler-wallet-cli 1.6.49) changed to exactly what suspendPayForLegalRefusal stores (txState suspended/submit-payment, txActions [resume, abort], error {code 2186, exchangeBaseUrls}). The JSON and the three dev-experiment URIs are attached (injected-getTransactionById.json, f203-*.uri). Each wallet renders that same object; nothing else is simulated. What the user sees: - WebExtension: "Payment paused" -- "The merchant cannot process this payment right now due to legal requirements." -- "After the merchant resolves the issue, resume this payment to retry with the same funds." -- buttons "Resume payment" and "Cancel payment" (before-web-1.png). - Android: "This transaction is suspended", amounts, order; the only button is "Abort" (before-android-1.png). No reason. - iOS: "Suspended", order, amounts, "Done"; no reason and no Resume (before-ios-1.png). On iOS (origin/master 674b7915): - TalerWallet1/Views/Transactions/TransactionSummaryList.swift:320: suspendResume() only if developerMode && withActions; from the history only Abort is offered (abortFailDelete, :347). - TalerWallet1/Views/Transactions/PaidTransactionDetailV.swift:96-114: shows failReason and abortReason only; common.error (Model/Transaction.swift:367) is never rendered for payments. - TalerWallet1/Model/Transaction.swift:114: the state text "Suspended" is an unlocalized literal. Reference: web routes/payment-model.ts:91-114, routes/App.tsx:4484-4489, screens/PaymentScreen.tsx:672-704, ui/error.ts:67-70,95-97. Patch (ios-legally-refused-payment-suspended-silently.patch): - TransactionSummaryList: outside developer mode, a resumable transaction gets a Resume button (suspendResume(common, resumeOnly: true)); developer mode keeps both buttons as before. - PaidTransactionDetailV: for a suspended payment with error 2186/2184, show the reason and the resume hint (three new English entries in Localizable.xcstrings, wording taken from the WebExtension). Verified: GNU_Taler Release builds for the simulator; after-ios-1.png is the same injected transaction with the patch: "Resume" button, reason and hint. Related: 0011416 (origin), 0012068 (generic: pending transaction error hidden, no Retry), 0012075 (Android counterpart). Assisted by LLM | ||||
| Steps To Reproduce | Real trigger: pay an order at a merchant whose bank account has not passed KYC at an exchange with a DEPOSIT KYC rule; the merchant's /pay answers 451 (2186) and wallet-core pauses the payment. Deterministic (wallet-core dev experiments, developer mode needed to apply them): 1. Apply the three attached URIs in order: f203-prepare.uri, f203-wait.uri, f203-get.uri (iOS: f203-get-small.uri, the same JSON without contractTerms -- the long URI fails to apply on iOS). 2. Turn developer mode off again (to see what users see), open any taler://pay/ link: the app shows the injected paused payment. | ||||
| Tags | No tags attached. | ||||
| Attached Files | injected-getTransactionById.json (2,532 bytes)
{
"type": "payment",
"merchantBaseUrl": "https://tripwire.taler.net:8450/instances/fuzz/",
"txState": {
"major": "suspended",
"minor": "submit-payment"
},
"stId": 17825793,
"scopes": [
{
"currency": "KUDOS",
"type": "exchange",
"url": "https://exchange.demo.taler.net/"
}
],
"txActions": [
"resume",
"abort"
],
"amountRaw": "KUDOS:1",
"amountEffective": "KUDOS:1",
"totalRefundRaw": "KUDOS:0",
"totalRefundEffective": "KUDOS:0",
"refunds": [],
"timestamp": {
"t_s": 1794359781,
"off_us": 687000
},
"transactionId": "txn:payment:PXFC0E245FSSXYD7QJ8FRPVZ8S9BZVB0BTMS0C4A2RV7R87XXYR0",
"info": {
"merchant": {
"name": "Fuzz harness shop",
"address": {
"country": "CH"
},
"jurisdiction": {
"country": "CH"
}
},
"orderId": "wc-20261111T011621-41e475",
"summary": "F203 payment paused because the exchange refused the merchant",
"contractTermsHash": "YR87NQR1AEZ9DQ65HPQDDN65D4K0PR80WBM4B2CMZ45F6T56EYDHG2880A6YYJJSXR1AQPQ191BFZQDVMJ4PHC4E47DQB9H9ZC0Q7Z0",
"fulfillmentUrl": "https://example.com/wc/wc-20261111T011621-41e475"
},
"contractTerms": {
"version": 0,
"amount": "KUDOS:1",
"max_fee": "KUDOS:0",
"order_id": "wc-20261111T011621-41e475",
"fulfillment_url": "https://example.com/wc/wc-20261111T011621-41e475",
"merchant_base_url": "https://tripwire.taler.net:8450/instances/fuzz/",
"h_wire": "C8C40YCCT4HM9RDQVCDJ2ZKEJF5RJQC1M698N1PQ9M935V9D8C8QVC1MXK3NNH6CZWFVWMNHH11E1DS665CYR50VRK3DQ36ED8BDH3R",
"auto_refund": {
"d_us": 0
},
"wire_method": "x-taler-bank",
"summary": "F203 payment paused because the exchange refused the merchant",
"nonce": "0SNDJTQJ34Q2H6E5X4Q3WNM974RKGAZF5NEHVZZ5WB1ZEB51RC3G",
"pay_deadline": {
"t_s": 1794446181
},
"refund_deadline": {
"t_s": 1795742181
},
"wire_transfer_deadline": {
"t_s": 1795742241
},
"timestamp": {
"t_s": 1794359781
},
"merchant": {
"name": "Fuzz harness shop",
"address": {
"country": "CH"
},
"jurisdiction": {
"country": "CH"
}
},
"merchant_pub": "PFRN7ZDFACVDNGJZKG0PW6994NN84VZRPPAP2RTYMREPANT9PA40",
"exchanges": [
{
"master_pub": "F80MFRG8HVH6R9CQ47KRFQSJP3T6DBJ4K1D9B703RJY3Z39TBMJ0",
"priority": 1024,
"url": "https://exchange.demo.taler.net/",
"max_contribution": "KUDOS:1"
}
],
"products": [],
"minimum_age": 0
},
"refundQueryActive": false,
"localTransactionId": "#payment:1",
"error": {
"code": 2186,
"exchangeBaseUrls": [
"https://exchange.demo.taler.net/"
]
}
} f203-prepare.uri (194 bytes)
taler://dev-experiment/api-response?operation=preparePayForUriV2&count=1&response=eyJ0cmFuc2FjdGlvbklkIjoidHhuOnBheW1lbnQ6UFhGQzBFMjQ1RlNTWFlEN1FKOEZSUFZaOFM5QlpWQjBCVE1TMEM0QTJSVjdSODdYWFlSMCJ9 f203-wait.uri (185 bytes)
taler://dev-experiment/api-response?operation=waitTransactionState&count=10&response=eyJtYXRjaGVkIjoiYmFpbCIsInR4U3RhdGUiOnsibWFqb3IiOiJzdXNwZW5kZWQiLCJtaW5vciI6InN1Ym1pdC1wYXltZW50In19 f203-get.uri (2,955 bytes)
taler://dev-experiment/api-response?operation=getTransactionById&count=40&response=eyJ0eXBlIjoicGF5bWVudCIsIm1lcmNoYW50QmFzZVVybCI6Imh0dHBzOi8vdHJpcHdpcmUudGFsZXIubmV0Ojg0NTAvaW5zdGFuY2VzL2Z1enovIiwidHhTdGF0ZSI6eyJtYWpvciI6InN1c3BlbmRlZCIsIm1pbm9yIjoic3VibWl0LXBheW1lbnQifSwic3RJZCI6MTc4MjU3OTMsInNjb3BlcyI6W3siY3VycmVuY3kiOiJLVURPUyIsInR5cGUiOiJleGNoYW5nZSIsInVybCI6Imh0dHBzOi8vZXhjaGFuZ2UuZGVtby50YWxlci5uZXQvIn1dLCJ0eEFjdGlvbnMiOlsicmVzdW1lIiwiYWJvcnQiXSwiYW1vdW50UmF3IjoiS1VET1M6MSIsImFtb3VudEVmZmVjdGl2ZSI6IktVRE9TOjEiLCJ0b3RhbFJlZnVuZFJhdyI6IktVRE9TOjAiLCJ0b3RhbFJlZnVuZEVmZmVjdGl2ZSI6IktVRE9TOjAiLCJyZWZ1bmRzIjpbXSwidGltZXN0YW1wIjp7InRfcyI6MTc5NDM1OTc4MSwib2ZmX3VzIjo2ODcwMDB9LCJ0cmFuc2FjdGlvbklkIjoidHhuOnBheW1lbnQ6UFhGQzBFMjQ1RlNTWFlEN1FKOEZSUFZaOFM5QlpWQjBCVE1TMEM0QTJSVjdSODdYWFlSMCIsImluZm8iOnsibWVyY2hhbnQiOnsibmFtZSI6IkZ1enogaGFybmVzcyBzaG9wIiwiYWRkcmVzcyI6eyJjb3VudHJ5IjoiQ0gifSwianVyaXNkaWN0aW9uIjp7ImNvdW50cnkiOiJDSCJ9fSwib3JkZXJJZCI6IndjLTIwMjYxMTExVDAxMTYyMS00MWU0NzUiLCJzdW1tYXJ5IjoiRjIwMyBwYXltZW50IHBhdXNlZCBiZWNhdXNlIHRoZSBleGNoYW5nZSByZWZ1c2VkIHRoZSBtZXJjaGFudCIsImNvbnRyYWN0VGVybXNIYXNoIjoiWVI4N05RUjFBRVo5RFE2NUhQUURETjY1RDRLMFBSODBXQk00QjJDTVo0NUY2VDU2RVlESEcyODgwQTZZWUpKU1hSMUFRUFExOTFCRlpRRFZNSjRQSEM0RTQ3RFFCOUg5WkMwUTdaMCIsImZ1bGZpbGxtZW50VXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS93Yy93Yy0yMDI2MTExMVQwMTE2MjEtNDFlNDc1In0sImNvbnRyYWN0VGVybXMiOnsidmVyc2lvbiI6MCwiYW1vdW50IjoiS1VET1M6MSIsIm1heF9mZWUiOiJLVURPUzowIiwib3JkZXJfaWQiOiJ3Yy0yMDI2MTExMVQwMTE2MjEtNDFlNDc1IiwiZnVsZmlsbG1lbnRfdXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS93Yy93Yy0yMDI2MTExMVQwMTE2MjEtNDFlNDc1IiwibWVyY2hhbnRfYmFzZV91cmwiOiJodHRwczovL3RyaXB3aXJlLnRhbGVyLm5ldDo4NDUwL2luc3RhbmNlcy9mdXp6LyIsImhfd2lyZSI6IkM4QzQwWUNDVDRITTlSRFFWQ0RKMlpLRUpGNVJKUUMxTTY5OE4xUFE5TTkzNVY5RDhDOFFWQzFNWEszTk5INkNaV0ZWV01OSEgxMUUxRFM2NjVDWVI1MFZSSzNEUTM2RUQ4QkRIM1IiLCJhdXRvX3JlZnVuZCI6eyJkX3VzIjowfSwid2lyZV9tZXRob2QiOiJ4LXRhbGVyLWJhbmsiLCJzdW1tYXJ5IjoiRjIwMyBwYXltZW50IHBhdXNlZCBiZWNhdXNlIHRoZSBleGNoYW5nZSByZWZ1c2VkIHRoZSBtZXJjaGFudCIsIm5vbmNlIjoiMFNOREpUUUozNFEySDZFNVg0UTNXTk05NzRSS0dBWkY1TkVIVlpaNVdCMVpFQjUxUkMzRyIsInBheV9kZWFkbGluZSI6eyJ0X3MiOjE3OTQ0NDYxODF9LCJyZWZ1bmRfZGVhZGxpbmUiOnsidF9zIjoxNzk1NzQyMTgxfSwid2lyZV90cmFuc2Zlcl9kZWFkbGluZSI6eyJ0X3MiOjE3OTU3NDIyNDF9LCJ0aW1lc3RhbXAiOnsidF9zIjoxNzk0MzU5NzgxfSwibWVyY2hhbnQiOnsibmFtZSI6IkZ1enogaGFybmVzcyBzaG9wIiwiYWRkcmVzcyI6eyJjb3VudHJ5IjoiQ0gifSwianVyaXNkaWN0aW9uIjp7ImNvdW50cnkiOiJDSCJ9fSwibWVyY2hhbnRfcHViIjoiUEZSTjdaREZBQ1ZETkdKWktHMFBXNjk5NE5OODRWWlJQUEFQMlJUWU1SRVBBTlQ5UEE0MCIsImV4Y2hhbmdlcyI6W3sibWFzdGVyX3B1YiI6IkY4ME1GUkc4SFZINlI5Q1E0N0tSRlFTSlAzVDZEQko0SzFEOUI3MDNSSlkzWjM5VEJNSjAiLCJwcmlvcml0eSI6MTAyNCwidXJsIjoiaHR0cHM6Ly9leGNoYW5nZS5kZW1vLnRhbGVyLm5ldC8iLCJtYXhfY29udHJpYnV0aW9uIjoiS1VET1M6MSJ9XSwicHJvZHVjdHMiOltdLCJtaW5pbXVtX2FnZSI6MH0sInJlZnVuZFF1ZXJ5QWN0aXZlIjpmYWxzZSwibG9jYWxUcmFuc2FjdGlvbklkIjoiI3BheW1lbnQ6MSIsImVycm9yIjp7ImNvZGUiOjIxODYsImV4Y2hhbmdlQmFzZVVybHMiOlsiaHR0cHM6Ly9leGNoYW5nZS5kZW1vLnRhbGVyLm5ldC8iXX19 f203-get-small.uri (1,531 bytes)
taler://dev-experiment/api-response?operation=getTransactionById&count=40&response=eyJ0eXBlIjoicGF5bWVudCIsIm1lcmNoYW50QmFzZVVybCI6Imh0dHBzOi8vdHJpcHdpcmUudGFsZXIubmV0Ojg0NTAvaW5zdGFuY2VzL2Z1enovIiwidHhTdGF0ZSI6eyJtYWpvciI6InN1c3BlbmRlZCIsIm1pbm9yIjoic3VibWl0LXBheW1lbnQifSwic3RJZCI6MTc4MjU3OTMsInNjb3BlcyI6W3siY3VycmVuY3kiOiJLVURPUyIsInR5cGUiOiJleGNoYW5nZSIsInVybCI6Imh0dHBzOi8vZXhjaGFuZ2UuZGVtby50YWxlci5uZXQvIn1dLCJ0eEFjdGlvbnMiOlsicmVzdW1lIiwiYWJvcnQiXSwiYW1vdW50UmF3IjoiS1VET1M6MSIsImFtb3VudEVmZmVjdGl2ZSI6IktVRE9TOjEiLCJ0b3RhbFJlZnVuZFJhdyI6IktVRE9TOjAiLCJ0b3RhbFJlZnVuZEVmZmVjdGl2ZSI6IktVRE9TOjAiLCJyZWZ1bmRzIjpbXSwidGltZXN0YW1wIjp7InRfcyI6MTc5NDM1OTc4MSwib2ZmX3VzIjo2ODcwMDB9LCJ0cmFuc2FjdGlvbklkIjoidHhuOnBheW1lbnQ6UFhGQzBFMjQ1RlNTWFlEN1FKOEZSUFZaOFM5QlpWQjBCVE1TMEM0QTJSVjdSODdYWFlSMCIsImluZm8iOnsibWVyY2hhbnQiOnsibmFtZSI6IkZ1enogaGFybmVzcyBzaG9wIiwiYWRkcmVzcyI6eyJjb3VudHJ5IjoiQ0gifSwianVyaXNkaWN0aW9uIjp7ImNvdW50cnkiOiJDSCJ9fSwib3JkZXJJZCI6IndjLTIwMjYxMTExVDAxMTYyMS00MWU0NzUiLCJzdW1tYXJ5IjoiRjIwMyBwYXltZW50IHBhdXNlZCBiZWNhdXNlIHRoZSBleGNoYW5nZSByZWZ1c2VkIHRoZSBtZXJjaGFudCIsImNvbnRyYWN0VGVybXNIYXNoIjoiWVI4N05RUjFBRVo5RFE2NUhQUURETjY1RDRLMFBSODBXQk00QjJDTVo0NUY2VDU2RVlESEcyODgwQTZZWUpKU1hSMUFRUFExOTFCRlpRRFZNSjRQSEM0RTQ3RFFCOUg5WkMwUTdaMCIsImZ1bGZpbGxtZW50VXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS93Yy93Yy0yMDI2MTExMVQwMTE2MjEtNDFlNDc1In0sInJlZnVuZFF1ZXJ5QWN0aXZlIjpmYWxzZSwibG9jYWxUcmFuc2FjdGlvbklkIjoiI3BheW1lbnQ6MSIsImVycm9yIjp7ImNvZGUiOjIxODYsImV4Y2hhbmdlQmFzZVVybHMiOlsiaHR0cHM6Ly9leGNoYW5nZS5kZW1vLnRhbGVyLm5ldC8iXX19 ios-legally-refused-payment-suspended-silently.patch (4,027 bytes)
diff --git a/TalerWallet1/Resources/Localizable.xcstrings b/TalerWallet1/Resources/Localizable.xcstrings
index e283ca45..b7d2fade 100755
--- a/TalerWallet1/Resources/Localizable.xcstrings
+++ b/TalerWallet1/Resources/Localizable.xcstrings
@@ -2842,6 +2842,9 @@
}
}
},
+ "After the merchant resolves the issue, resume this payment to retry with the same funds." : {
+ "comment" : "below the reason why a payment was paused"
+ },
"Alias Type:" : {
"comment" : "Picker title",
"localizations" : {
@@ -19138,6 +19141,12 @@
}
}
},
+ "The merchant cannot process this payment right now because a payment service transaction limit was reached." : {
+ "comment" : "payment paused by wallet-core: transaction limit at the merchant's exchange"
+ },
+ "The merchant cannot process this payment right now due to legal requirements." : {
+ "comment" : "payment paused by wallet-core: the merchant's exchange refused the deposit (e.g. missing KYC)"
+ },
"The payment service is waiting for your wire-transfer." : {
"localizations" : {
"de" : {
diff --git a/TalerWallet1/Views/Transactions/PaidTransactionDetailV.swift b/TalerWallet1/Views/Transactions/PaidTransactionDetailV.swift
index 6a6b2152..11951d2f 100755
--- a/TalerWallet1/Views/Transactions/PaidTransactionDetailV.swift
+++ b/TalerWallet1/Views/Transactions/PaidTransactionDetailV.swift
@@ -93,6 +93,15 @@ struct PaidTransactionDetailV: View {
.talerFont(.body)
}
}
+ if common.isSuspended, let code = common.error?.code,
+ code == 2186 || code == 2184 { // MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED / _TRANSACTION_LIMIT_VIOLATION
+ Text(code == 2186
+ ? String(localized: "The merchant cannot process this payment right now due to legal requirements.")
+ : String(localized: "The merchant cannot process this payment right now because a payment service transaction limit was reached."))
+ .talerFont(.title3)
+ Text("After the merchant resolves the issue, resume this payment to retry with the same funds.")
+ .talerFont(.body)
+ }
if let failReason = common.failReason {
if let hint = failReason.hint {
Text(hint)
diff --git a/TalerWallet1/Views/Transactions/TransactionSummaryList.swift b/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
index 74f04b65..218cb87a 100755
--- a/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
+++ b/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
@@ -228,8 +228,8 @@ struct TransactionSummaryList: View {
}
@ViewBuilder
- func suspendResume(_ common: TransactionCommon) -> some View {
- if talerTX.isSuspendable {
+ func suspendResume(_ common: TransactionCommon, resumeOnly: Bool = false) -> some View {
+ if talerTX.isSuspendable && !resumeOnly {
TransactionButton(transactionId: common.transactionId,
command: .suspend,
warning: nil,
@@ -317,7 +317,13 @@ struct TransactionSummaryList: View {
Group {
if common.type != .dummy && transactionId == common.transactionId {
let list = List {
- if developerMode && withActions { suspendResume(common) }
+ if developerMode && withActions {
+ suspendResume(common)
+ } else if talerTX.isResumable {
+ // wallet-core suspends some transactions by itself (e.g. a payment the
+ // merchant's exchange refused for legal reasons); Resume is the only way on
+ suspendResume(common, resumeOnly: true)
+ }
if !isPayDialog {
Section {
HStack {
| ||||
| related to | 0012075 | new | Android: a payment the merchant's exchange refused for legal reasons (KYC, 2186/2184) is only "suspended" -- no reason, and Resume is hidden outside developer mode, so it can only be aborted (patch attached) |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-11 01:35 | bot-cg | New Issue | |
| 2026-11-11 01:35 | bot-cg | Relationship added | related to 0012075 |
| 2026-11-11 01:36 | bot-cg | File Added: before-android-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: before-ios-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: before-web-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: injected-getTransactionById.json | |
| 2026-11-11 01:36 | bot-cg | File Added: f203-prepare.uri | |
| 2026-11-11 01:36 | bot-cg | File Added: f203-wait.uri | |
| 2026-11-11 01:36 | bot-cg | File Added: f203-get.uri | |
| 2026-11-11 01:36 | bot-cg | File Added: f203-get-small.uri | |
| 2026-11-11 01:36 | bot-cg | File Added: after-ios-1.png | |
| 2026-11-11 01:36 | bot-cg | File Added: ios-legally-refused-payment-suspended-silently.patch |