View Issue Details

IDProjectCategoryView StatusLast Update
0011978Talerwallet-corepublic2026-11-09 09:13
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
PrioritynormalSeverityminorReproducibilityalways
Status newResolutionopen 
Summary0011978: wallet-core: a peer-to-peer credit blocked on KYC sets no incoming-kyc balance flag, so wallets cannot tell it from a slow transfer (patch + tests attached)
Description`getBalancesInternal` raises `incoming-kyc` for a withdrawal blocked on
KYC and `outgoing-kyc` for a deposit blocked on KYC, but raises nothing for
a **peer-push or peer-pull credit** blocked on KYC. The amount is added to
`pendingIncoming` and that is all, so at the balance level a transfer that
is waiting for the exchange and one that is waiting for the *user* are
indistinguishable.

That is the wallet-core half of bug 11914 ("withdrawal runs into KYC ...
but iOS wallet gives no chance to user to DO the KYC"): a wallet that wants
to badge the balance row cannot, because the flag it would badge on is
never set.

Where
-----
`packages/taler-wallet-core/src/balance.ts`, in `getBalancesInternal`:

* **withdrawals, :719-735** -- `PendingKyc`, `PendingBalanceKyc`,
  `PendingBalanceKycInit`, `SuspendedKyc`, `SuspendedBalanceKyc`,
  `SuspendedBalanceKycInit` all call `setFlagIncomingKyc`.
* **deposit groups, :943-951** -- the six `*DepositKyc*` / `*AggregateKyc`
  statuses call `setFlagOutgoingKyc`, in a switch of their own.
* **peer push credits, :818-844** -- `PendingMergeKycRequired`,
  `PendingBalanceKycRequired`, `PendingBalanceKycInit` and their
  `Suspended*` twins fall into the same arm as the ordinary `PendingMerge`
  and call only `addPendingIncoming`. No flag.
* **peer pull credits, :846-869** -- same, with `PendingReady` and
  `PendingCreatePurse` in the same arm.

Observed
--------
Two `taler-wallet-cli` wallets against an exchange with a `MERGE` rule at
KUDOS:2. Wallet A pushes a KUDOS:2.5 purse, wallet B accepts it:

    getTransactions ->
      peer-push-credit pending / kyc KUDOS:2.5 kycUrl=<set>

    getBalances ->
      pendingIncoming = KUDOS:2.5 flags = []

The transaction knows it needs KYC and carries the URL. The balance does
not say so.

Patch
-----
Attached (`wallet-core-incoming-kyc-flag.diff`), against c7511e514. It adds
a flag-only `switch` to each of the two loops, in the same shape the deposit
loop already uses, and two unit tests in `balance.test.ts`.

Verified on the same wallet database, same commit, one build with the patch
and one without:

    without: pendingIncoming = KUDOS:2.5 flags = []
    with: pendingIncoming = KUDOS:2.5 flags = ['incoming-kyc']

`tsc --noEmit` clean, `prettier --check` clean, `node --test lib/balance.test.js`
13/13. The new test fails on unpatched `balance.ts` ("not ok 12") and
passes with it, so it pins the behaviour rather than describing it.

Judgement calls worth a reviewer's eye
--------------------------------------
* `*BalanceKycInit` is included, to match the withdrawal loop, which
  already flags `PendingBalanceKycInit`. Note that iOS deliberately does
  *not* show its KYC button in the transaction's `kyc-init` state (bug
  10214), so a wallet may want to badge the balance while the button is
  still hidden; the two are different questions and the flag answers the
  first.
* `PendingWithdrawing` is left alone: the balance there is covered by the
  internal withdrawal group, which raises its own flag if it needs to.
TagsNo tags attached.
Attached Files
wallet-core-incoming-kyc-flag.diff (6,946 bytes)   
diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts
index f3576906a..66261e1ee 100644
--- a/packages/taler-wallet-core/src/balance.test.ts
+++ b/packages/taler-wallet-core/src/balance.test.ts
@@ -17,6 +17,7 @@ import { test } from "node:test";
 
 import {
   Amounts,
+  BalanceFlag,
   RefreshReason,
   ScopeType,
   stringifyScopeInfo,
@@ -29,6 +30,8 @@ import {
   ExchangeEntryDbRecordStatus,
   ExchangeEntryDbUpdateStatus,
   DepositOperationStatus,
+  PeerPushCreditStatus,
+  PeerPullPaymentCreditStatus,
   PurchaseStatus,
   RefreshOperationStatus,
   WalletCoinAvailability,
@@ -36,6 +39,8 @@ import {
   WalletExchangeEntry,
   WalletRefreshGroup,
   WalletDepositGroup,
+  WalletPeerPushCredit,
+  WalletPeerPullCredit,
 } from "./db/records.js";
 import { WalletDbTransaction } from "./db/transaction.js";
 import { WalletExecutionContext } from "./wallet.js";
@@ -628,3 +633,81 @@ test("active purchases share one deduplicated coin lookup", async () => {
   assert.strictEqual(byUrl.get(exchangeA)?.pendingOutgoing, "TESTKUDOS:4");
   assert.strictEqual(byUrl.get(exchangeB)?.pendingOutgoing, "TESTKUDOS:2");
 });
+
+test("a peer credit blocked on KYC raises the incoming-kyc flag", async () => {
+  // Without the flag a wallet cannot tell, at the balance level, a transfer
+  // that is waiting for the exchange from one that is waiting for the user.
+  // Both show up only as pendingIncoming, which is what left the iOS wallet
+  // showing a bare "Pending incoming" row for a receipt the user had to act
+  // on (bugs.taler.net/n/11914).  Withdrawals and deposits have always
+  // raised their flag; peer credits did not.
+  const exchange = "https://peer-kyc.example/";
+  for (const status of [
+    PeerPushCreditStatus.PendingMergeKycRequired,
+    PeerPushCreditStatus.PendingBalanceKycRequired,
+    PeerPushCreditStatus.PendingBalanceKycInit,
+    PeerPushCreditStatus.SuspendedMergeKycRequired,
+  ]) {
+    const { wex, tx } = makeBalanceContext([makeExchange(exchange)]);
+    tx.getActivePeerPushCredits = async () => [
+      {
+        exchangeBaseUrl: exchange,
+        estimatedAmountEffective: "TESTKUDOS:2.5",
+        status,
+      } as unknown as WalletPeerPushCredit,
+    ];
+    const balances = await getBalancesInsideTransaction(wex, tx);
+    const b = balances.balances.find(
+      (x) => x.scopeInfo.type === ScopeType.Exchange,
+    );
+    assert.strictEqual(b?.pendingIncoming, "TESTKUDOS:2.5", `push ${status}`);
+    assert.ok(
+      b?.flags.includes(BalanceFlag.IncomingKyc),
+      `push ${status} should raise incoming-kyc, got ${JSON.stringify(b?.flags)}`,
+    );
+  }
+
+  for (const status of [
+    PeerPullPaymentCreditStatus.PendingMergeKycRequired,
+    PeerPullPaymentCreditStatus.PendingBalanceKycRequired,
+    PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired,
+  ]) {
+    const { wex, tx } = makeBalanceContext([makeExchange(exchange)]);
+    tx.getActivePeerPullCredits = async () => [
+      {
+        exchangeBaseUrl: exchange,
+        amount: "TESTKUDOS:2.5",
+        estimatedAmountEffective: "TESTKUDOS:2.5",
+        status,
+      } as unknown as WalletPeerPullCredit,
+    ];
+    const balances = await getBalancesInsideTransaction(wex, tx);
+    const b = balances.balances.find(
+      (x) => x.scopeInfo.type === ScopeType.Exchange,
+    );
+    assert.ok(
+      b?.flags.includes(BalanceFlag.IncomingKyc),
+      `pull ${status} should raise incoming-kyc, got ${JSON.stringify(b?.flags)}`,
+    );
+  }
+});
+
+test("a peer credit that is merely pending raises no flag", async () => {
+  // The other half of the claim: the flag has to mean "waiting for you", so
+  // an ordinary pending merge must not set it.
+  const exchange = "https://peer-plain.example/";
+  const { wex, tx } = makeBalanceContext([makeExchange(exchange)]);
+  tx.getActivePeerPushCredits = async () => [
+    {
+      exchangeBaseUrl: exchange,
+      estimatedAmountEffective: "TESTKUDOS:2.5",
+      status: PeerPushCreditStatus.PendingMerge,
+    } as unknown as WalletPeerPushCredit,
+  ];
+  const balances = await getBalancesInsideTransaction(wex, tx);
+  const b = balances.balances.find(
+    (x) => x.scopeInfo.type === ScopeType.Exchange,
+  );
+  assert.strictEqual(b?.pendingIncoming, "TESTKUDOS:2.5");
+  assert.deepStrictEqual(b?.flags, []);
+});
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
index b13a9b3d1..0a6631054 100644
--- a/packages/taler-wallet-core/src/balance.ts
+++ b/packages/taler-wallet-core/src/balance.ts
@@ -817,6 +817,27 @@ export async function getBalancesInsideTransaction(
 
   const peerPushCredits = await tx.getActivePeerPushCredits();
   for (const rec of peerPushCredits) {
+    // A separate switch for the flag, the way the deposit-group loop below
+    // does it: the pending-amount cases and the needs-the-user cases are
+    // different questions and overlap only partly.
+    //
+    // Without this, a wallet cannot tell the two apart at the balance
+    // level.  The amount shows up as pendingIncoming either way, so a
+    // transfer that is waiting for the exchange and one that is waiting
+    // for the user to pass a legitimization check render identically, and
+    // the only way to find out which is to open the transaction.
+    switch (rec.status) {
+      case PeerPushCreditStatus.PendingBalanceKycInit:
+      case PeerPushCreditStatus.PendingBalanceKycRequired:
+      case PeerPushCreditStatus.PendingMergeKycRequired:
+      case PeerPushCreditStatus.SuspendedBalanceKycInit:
+      case PeerPushCreditStatus.SuspendedBalanceKycRequired:
+      case PeerPushCreditStatus.SuspendedMergeKycRequired:
+        await balanceStore.setFlagIncomingKyc(
+          Amounts.currencyOf(rec.estimatedAmountEffective),
+          rec.exchangeBaseUrl,
+        );
+    }
     switch (rec.status) {
       case PeerPushCreditStatus.PendingMerge:
       case PeerPushCreditStatus.PendingBalanceKycInit:
@@ -845,6 +866,18 @@ export async function getBalancesInsideTransaction(
 
   const peerPullCredits = await tx.getActivePeerPullCredits();
   for (const rec of peerPullCredits) {
+    switch (rec.status) {
+      case PeerPullPaymentCreditStatus.PendingBalanceKycInit:
+      case PeerPullPaymentCreditStatus.PendingBalanceKycRequired:
+      case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+      case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit:
+      case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired:
+      case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+        await balanceStore.setFlagIncomingKyc(
+          Amounts.currencyOf(rec.amount),
+          rec.exchangeBaseUrl,
+        );
+    }
     switch (rec.status) {
       case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit:
       case PeerPullPaymentCreditStatus.PendingBalanceKycInit:

Required notifications

Address or linkRemarkAdded byNotifiedActions

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-09 09:13bot-cgNew Issue
2026-11-09 09:13bot-cgFile Added: wallet-core-incoming-kyc-flag.diff