gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated (29363d4a5 -> b0cc65e17)


From: gnunet
Subject: [taler-wallet-core] branch master updated (29363d4a5 -> b0cc65e17)
Date: Fri, 31 Mar 2023 17:27:58 +0200

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a change to branch master
in repository wallet-core.

    from 29363d4a5 more versions to bump
     new b74a387e5 typo
     new 7ebcb30b9 show taler bank in devMode
     new b0cc65e17 move coin selection function to coinSelection.ts and added a 
test placeholder, and some fixes:  * selectCandidates was not save wire fee  * 
selectCandidates show check wire fee time range

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 packages/taler-util/src/wallet-types.ts            |   2 +-
 .../taler-wallet-core/src/operations/deposits.ts   |   2 +-
 .../taler-wallet-core/src/operations/exchanges.ts  |   2 +-
 .../src/operations/pay-merchant.ts                 | 488 +----------------
 .../taler-wallet-core/src/operations/refresh.ts    |   6 +-
 .../taler-wallet-core/src/operations/withdraw.ts   | 162 +-----
 .../src/util/coinSelection.test.ts}                |  16 +-
 .../taler-wallet-core/src/util/coinSelection.ts    | 597 ++++++++++++++++++++-
 .../taler-wallet-core/src/util/denominations.ts    |  27 +
 .../src/wallet/ManageAccount/state.ts              |   7 +-
 .../src/wallet/Transaction.tsx                     |   2 +-
 11 files changed, 670 insertions(+), 641 deletions(-)
 copy packages/{demobank-ui/src/components/EmptyComponentExample/state.ts => 
taler-wallet-core/src/util/coinSelection.test.ts} (69%)

diff --git a/packages/taler-util/src/wallet-types.ts 
b/packages/taler-util/src/wallet-types.ts
index b08b02ca3..de84677ac 100644
--- a/packages/taler-util/src/wallet-types.ts
+++ b/packages/taler-util/src/wallet-types.ts
@@ -465,7 +465,7 @@ export interface PayMerchantInsufficientBalanceDetails {
 
   /**
    * If the payment would succeed without fees
-   * (i.e. balanceMechantWireable >= amountRequested),
+   * (i.e. balanceMerchantDepositable >= amountRequested),
    * this field contains an estimate of the amount that would additionally
    * be required to cover the fees.
    *
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts 
b/packages/taler-wallet-core/src/operations/deposits.ts
index c6cd4732c..64217acab 100644
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ b/packages/taler-wallet-core/src/operations/deposits.ts
@@ -76,9 +76,9 @@ import {
   extractContractData,
   generateDepositPermissions,
   getTotalPaymentCost,
-  selectPayCoinsNew,
 } from "./pay-merchant.js";
 import { getTotalRefreshCost } from "./refresh.js";
+import { selectPayCoinsNew } from "../util/coinSelection.js";
 
 /**
  * Logger.
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts 
b/packages/taler-wallet-core/src/operations/exchanges.ts
index 8a98c8299..d9051b32f 100644
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -63,6 +63,7 @@ import {
   ExchangeRecord,
   WalletStoresV1,
 } from "../db.js";
+import { isWithdrawableDenom } from "../index.js";
 import { InternalWalletState, TrustInfo } from "../internal-wallet-state.js";
 import { checkDbInvariant } from "../util/invariants.js";
 import {
@@ -78,7 +79,6 @@ import {
 } from "../util/retries.js";
 import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
 import { runOperationWithErrorReporting } from "./common.js";
-import { isWithdrawableDenom } from "./withdraw.js";
 
 const logger = new Logger("exchanges.ts");
 
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts 
b/packages/taler-wallet-core/src/operations/pay-merchant.ts
index 25153f9fb..f8fa1d34d 100644
--- a/packages/taler-wallet-core/src/operations/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -24,12 +24,10 @@
 /**
  * Imports.
  */
-import { GlobalIDB } from "@gnu-taler/idb-bridge";
 import {
   AbortingCoin,
   AbortRequest,
   AbsoluteTime,
-  AgeRestriction,
   AmountJson,
   Amounts,
   ApplyRefundResponse,
@@ -44,9 +42,8 @@ import {
   CoinStatus,
   ConfirmPayResult,
   ConfirmPayResultType,
-  MerchantContractTerms,
+  constructPayUri,
   ContractTermsUtil,
-  DenominationInfo,
   Duration,
   encodeCrock,
   ForcedCoinSel,
@@ -54,11 +51,13 @@ import {
   HttpStatusCode,
   j2s,
   Logger,
+  makeErrorDetail,
+  makePendingOperationFailedError,
   MerchantCoinRefundFailureStatus,
   MerchantCoinRefundStatus,
   MerchantCoinRefundSuccessStatus,
+  MerchantContractTerms,
   NotificationType,
-  parsePaytoUri,
   parsePayUri,
   parseRefundUri,
   PayCoinSelection,
@@ -66,19 +65,24 @@ import {
   PreparePayResultType,
   PrepareRefundResult,
   RefreshReason,
-  strcmp,
+  TalerError,
   TalerErrorCode,
   TalerErrorDetail,
   TalerProtocolTimestamp,
+  TalerProtocolViolationError,
   TransactionType,
   URL,
-  constructPayUri,
-  PayMerchantInsufficientBalanceDetails,
 } from "@gnu-taler/taler-util";
+import {
+  getHttpResponseErrorDetails,
+  readSuccessResponseJsonOrErrorCode,
+  readSuccessResponseJsonOrThrow,
+  readTalerErrorResponse,
+  readUnexpectedResponseDetails,
+  throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
 import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
 import {
-  AllowedAuditorInfo,
-  AllowedExchangeInfo,
   BackupProviderStateTag,
   CoinRecord,
   DenominationRecord,
@@ -89,51 +93,29 @@ import {
   WalletContractData,
   WalletStoresV1,
 } from "../db.js";
-import {
-  makeErrorDetail,
-  makePendingOperationFailedError,
-  TalerError,
-  TalerProtocolViolationError,
-} from "@gnu-taler/taler-util";
 import { GetReadWriteAccess, PendingTaskType } from "../index.js";
 import {
   EXCHANGE_COINS_LOCK,
   InternalWalletState,
 } from "../internal-wallet-state.js";
 import { assertUnreachable } from "../util/assertUnreachable.js";
+import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js";
+import { checkDbInvariant } from "../util/invariants.js";
+import { GetReadOnlyAccess } from "../util/query.js";
 import {
-  CoinSelectionTally,
-  PreviousPayCoins,
-  tallyFees,
-} from "../util/coinSelection.js";
-import {
-  getHttpResponseErrorDetails,
-  readSuccessResponseJsonOrErrorCode,
-  readSuccessResponseJsonOrThrow,
-  readTalerErrorResponse,
-  readUnexpectedResponseDetails,
-  throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
+  constructTaskIdentifier,
   OperationAttemptResult,
   OperationAttemptResultType,
   RetryInfo,
-  TaskIdentifiers,
   scheduleRetry,
-  constructTaskIdentifier,
+  TaskIdentifiers,
 } from "../util/retries.js";
 import {
   makeTransactionId,
   runOperationWithErrorReporting,
   spendCoins,
-  storeOperationError,
-  storeOperationPending,
 } from "./common.js";
-import { getExchangeDetails } from "./exchanges.js";
 import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-import { getMerchantPaymentBalanceDetails } from "./balance.js";
 
 /**
  * Logger.
@@ -877,434 +859,6 @@ async function unblockBackup(
     });
 }
 
-export interface SelectPayCoinRequestNg {
-  exchanges: AllowedExchangeInfo[];
-  auditors: AllowedAuditorInfo[];
-  wireMethod: string;
-  contractTermsAmount: AmountJson;
-  depositFeeLimit: AmountJson;
-  wireFeeLimit: AmountJson;
-  wireFeeAmortization: number;
-  prevPayCoins?: PreviousPayCoins;
-  requiredMinimumAge?: number;
-  forcedSelection?: ForcedCoinSel;
-}
-
-export type AvailableDenom = DenominationInfo & {
-  maxAge: number;
-  numAvailable: number;
-};
-
-export async function selectCandidates(
-  ws: InternalWalletState,
-  req: SelectPayCoinRequestNg,
-): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
-  return await ws.db
-    .mktx((x) => [
-      x.exchanges,
-      x.exchangeDetails,
-      x.denominations,
-      x.coinAvailability,
-    ])
-    .runReadOnly(async (tx) => {
-      // FIXME: Use the existing helper (from balance.ts) to
-      // get acceptable exchanges.
-      const denoms: AvailableDenom[] = [];
-      const exchanges = await tx.exchanges.iter().toArray();
-      const wfPerExchange: Record<string, AmountJson> = {};
-      for (const exchange of exchanges) {
-        const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
-        if (exchangeDetails?.currency !== req.contractTermsAmount.currency) {
-          continue;
-        }
-        let wireMethodSupported = false;
-        for (const acc of exchangeDetails.wireInfo.accounts) {
-          const pp = parsePaytoUri(acc.payto_uri);
-          checkLogicInvariant(!!pp);
-          if (pp.targetType === req.wireMethod) {
-            wireMethodSupported = true;
-            break;
-          }
-        }
-        if (!wireMethodSupported) {
-          break;
-        }
-        exchangeDetails.wireInfo.accounts;
-        let accepted = false;
-        for (const allowedExchange of req.exchanges) {
-          if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) 
{
-            accepted = true;
-            break;
-          }
-        }
-        for (const allowedAuditor of req.auditors) {
-          for (const providedAuditor of exchangeDetails.auditors) {
-            if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
-              accepted = true;
-              break;
-            }
-          }
-        }
-        if (!accepted) {
-          continue;
-        }
-        let ageLower = 0;
-        let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
-        if (req.requiredMinimumAge) {
-          ageLower = req.requiredMinimumAge;
-        }
-        const myExchangeDenoms =
-          await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
-            GlobalIDB.KeyRange.bound(
-              [exchangeDetails.exchangeBaseUrl, ageLower, 1],
-              [
-                exchangeDetails.exchangeBaseUrl,
-                ageUpper,
-                Number.MAX_SAFE_INTEGER,
-              ],
-            ),
-          );
-        // FIXME: Check that the individual denomination is audited!
-        // FIXME: Should we exclude denominations that are
-        // not spendable anymore?
-        for (const denomAvail of myExchangeDenoms) {
-          const denom = await tx.denominations.get([
-            denomAvail.exchangeBaseUrl,
-            denomAvail.denomPubHash,
-          ]);
-          checkDbInvariant(!!denom);
-          if (denom.isRevoked || !denom.isOffered) {
-            continue;
-          }
-          denoms.push({
-            ...DenominationRecord.toDenomInfo(denom),
-            numAvailable: denomAvail.freshCoinCount ?? 0,
-            maxAge: denomAvail.maxAge,
-          });
-        }
-      }
-      // Sort by available amount (descending),  deposit fee (ascending) and
-      // denomPub (ascending) if deposit fee is the same
-      // (to guarantee deterministic results)
-      denoms.sort(
-        (o1, o2) =>
-          -Amounts.cmp(o1.value, o2.value) ||
-          Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
-          strcmp(o1.denomPubHash, o2.denomPubHash),
-      );
-      return [denoms, wfPerExchange];
-    });
-}
-
-function makeAvailabilityKey(
-  exchangeBaseUrl: string,
-  denomPubHash: string,
-  maxAge: number,
-): string {
-  return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
-}
-
-/**
- * Selection result.
- */
-interface SelResult {
-  /**
-   * Map from an availability key
-   * to an array of contributions.
-   */
-  [avKey: string]: {
-    exchangeBaseUrl: string;
-    denomPubHash: string;
-    maxAge: number;
-    contributions: AmountJson[];
-  };
-}
-
-export function selectGreedy(
-  req: SelectPayCoinRequestNg,
-  candidateDenoms: AvailableDenom[],
-  wireFeesPerExchange: Record<string, AmountJson>,
-  tally: CoinSelectionTally,
-): SelResult | undefined {
-  const { wireFeeAmortization } = req;
-  const selectedDenom: SelResult = {};
-  for (const aci of candidateDenoms) {
-    const contributions: AmountJson[] = [];
-    for (let i = 0; i < aci.numAvailable; i++) {
-      // Don't use this coin if depositing it is more expensive than
-      // the amount it would give the merchant.
-      if (Amounts.cmp(aci.feeDeposit, aci.value) > 0) {
-        tally.lastDepositFee = Amounts.parseOrThrow(aci.feeDeposit);
-        continue;
-      }
-
-      if (Amounts.isZero(tally.amountPayRemaining)) {
-        // We have spent enough!
-        break;
-      }
-
-      tally = tallyFees(
-        tally,
-        wireFeesPerExchange,
-        wireFeeAmortization,
-        aci.exchangeBaseUrl,
-        Amounts.parseOrThrow(aci.feeDeposit),
-      );
-
-      let coinSpend = Amounts.max(
-        Amounts.min(tally.amountPayRemaining, aci.value),
-        aci.feeDeposit,
-      );
-
-      tally.amountPayRemaining = Amounts.sub(
-        tally.amountPayRemaining,
-        coinSpend,
-      ).amount;
-      contributions.push(coinSpend);
-    }
-
-    if (contributions.length) {
-      const avKey = makeAvailabilityKey(
-        aci.exchangeBaseUrl,
-        aci.denomPubHash,
-        aci.maxAge,
-      );
-      let sd = selectedDenom[avKey];
-      if (!sd) {
-        sd = {
-          contributions: [],
-          denomPubHash: aci.denomPubHash,
-          exchangeBaseUrl: aci.exchangeBaseUrl,
-          maxAge: aci.maxAge,
-        };
-      }
-      sd.contributions.push(...contributions);
-      selectedDenom[avKey] = sd;
-    }
-
-    if (Amounts.isZero(tally.amountPayRemaining)) {
-      return selectedDenom;
-    }
-  }
-  return undefined;
-}
-
-export function selectForced(
-  req: SelectPayCoinRequestNg,
-  candidateDenoms: AvailableDenom[],
-): SelResult | undefined {
-  const selectedDenom: SelResult = {};
-
-  const forcedSelection = req.forcedSelection;
-  checkLogicInvariant(!!forcedSelection);
-
-  for (const forcedCoin of forcedSelection.coins) {
-    let found = false;
-    for (const aci of candidateDenoms) {
-      if (aci.numAvailable <= 0) {
-        continue;
-      }
-      if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
-        aci.numAvailable--;
-        const avKey = makeAvailabilityKey(
-          aci.exchangeBaseUrl,
-          aci.denomPubHash,
-          aci.maxAge,
-        );
-        let sd = selectedDenom[avKey];
-        if (!sd) {
-          sd = {
-            contributions: [],
-            denomPubHash: aci.denomPubHash,
-            exchangeBaseUrl: aci.exchangeBaseUrl,
-            maxAge: aci.maxAge,
-          };
-        }
-        sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
-        selectedDenom[avKey] = sd;
-        found = true;
-        break;
-      }
-    }
-    if (!found) {
-      throw Error("can't find coin for forced coin selection");
-    }
-  }
-
-  return selectedDenom;
-}
-
-export type SelectPayCoinsResult =
-  | {
-      type: "failure";
-      insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
-    }
-  | { type: "success"; coinSel: PayCoinSelection };
-
-/**
- * Given a list of candidate coins, select coins to spend under the merchant's
- * constraints.
- *
- * The prevPayCoins can be specified to "repair" a coin selection
- * by adding additional coins, after a broken (e.g. double-spent) coin
- * has been removed from the selection.
- *
- * This function is only exported for the sake of unit tests.
- */
-export async function selectPayCoinsNew(
-  ws: InternalWalletState,
-  req: SelectPayCoinRequestNg,
-): Promise<SelectPayCoinsResult> {
-  const {
-    contractTermsAmount,
-    depositFeeLimit,
-    wireFeeLimit,
-    wireFeeAmortization,
-  } = req;
-
-  const [candidateDenoms, wireFeesPerExchange] = await selectCandidates(
-    ws,
-    req,
-  );
-
-  // logger.trace(`candidate denoms: ${j2s(candidateDenoms)}`);
-
-  const coinPubs: string[] = [];
-  const coinContributions: AmountJson[] = [];
-  const currency = contractTermsAmount.currency;
-
-  let tally: CoinSelectionTally = {
-    amountPayRemaining: contractTermsAmount,
-    amountWireFeeLimitRemaining: wireFeeLimit,
-    amountDepositFeeLimitRemaining: depositFeeLimit,
-    customerDepositFees: Amounts.zeroOfCurrency(currency),
-    customerWireFees: Amounts.zeroOfCurrency(currency),
-    wireFeeCoveredForExchange: new Set(),
-    lastDepositFee: Amounts.zeroOfCurrency(currency),
-  };
-
-  const prevPayCoins = req.prevPayCoins ?? [];
-
-  // Look at existing pay coin selection and tally up
-  for (const prev of prevPayCoins) {
-    tally = tallyFees(
-      tally,
-      wireFeesPerExchange,
-      wireFeeAmortization,
-      prev.exchangeBaseUrl,
-      prev.feeDeposit,
-    );
-    tally.amountPayRemaining = Amounts.sub(
-      tally.amountPayRemaining,
-      prev.contribution,
-    ).amount;
-
-    coinPubs.push(prev.coinPub);
-    coinContributions.push(prev.contribution);
-  }
-
-  let selectedDenom: SelResult | undefined;
-  if (req.forcedSelection) {
-    selectedDenom = selectForced(req, candidateDenoms);
-  } else {
-    // FIXME:  Here, we should select coins in a smarter way.
-    // Instead of always spending the next-largest coin,
-    // we should try to find the smallest coin that covers the
-    // amount.
-    selectedDenom = selectGreedy(
-      req,
-      candidateDenoms,
-      wireFeesPerExchange,
-      tally,
-    );
-  }
-
-  if (!selectedDenom) {
-    const details = await getMerchantPaymentBalanceDetails(ws, {
-      acceptedAuditors: req.auditors,
-      acceptedExchanges: req.exchanges,
-      acceptedWireMethods: [req.wireMethod],
-      currency: Amounts.currencyOf(req.contractTermsAmount),
-      minAge: req.requiredMinimumAge ?? 0,
-    });
-    let feeGapEstimate: AmountJson;
-    if (
-      Amounts.cmp(
-        details.balanceMerchantDepositable,
-        req.contractTermsAmount,
-      ) >= 0
-    ) {
-      // FIXME: We can probably give a better estimate.
-      feeGapEstimate = Amounts.add(
-        tally.amountPayRemaining,
-        tally.lastDepositFee,
-      ).amount;
-    } else {
-      feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount);
-    }
-    return {
-      type: "failure",
-      insufficientBalanceDetails: {
-        amountRequested: Amounts.stringify(req.contractTermsAmount),
-        balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
-        balanceAvailable: Amounts.stringify(details.balanceAvailable),
-        balanceMaterial: Amounts.stringify(details.balanceMaterial),
-        balanceMerchantAcceptable: Amounts.stringify(
-          details.balanceMerchantAcceptable,
-        ),
-        balanceMerchantDepositable: Amounts.stringify(
-          details.balanceMerchantDepositable,
-        ),
-        feeGapEstimate: Amounts.stringify(feeGapEstimate),
-      },
-    };
-  }
-
-  const finalSel = selectedDenom;
-
-  logger.trace(`coin selection request ${j2s(req)}`);
-  logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`);
-
-  await ws.db
-    .mktx((x) => [x.coins, x.denominations])
-    .runReadOnly(async (tx) => {
-      for (const dph of Object.keys(finalSel)) {
-        const selInfo = finalSel[dph];
-        const numRequested = selInfo.contributions.length;
-        const query = [
-          selInfo.exchangeBaseUrl,
-          selInfo.denomPubHash,
-          selInfo.maxAge,
-          CoinStatus.Fresh,
-        ];
-        logger.info(`query: ${j2s(query)}`);
-        const coins =
-          await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
-            query,
-            numRequested,
-          );
-        if (coins.length != numRequested) {
-          throw Error(
-            `coin selection failed (not available anymore, got only 
${coins.length}/${numRequested})`,
-          );
-        }
-        coinPubs.push(...coins.map((x) => x.coinPub));
-        coinContributions.push(...selInfo.contributions);
-      }
-    });
-
-  return {
-    type: "success",
-    coinSel: {
-      paymentAmount: Amounts.stringify(contractTermsAmount),
-      coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
-      coinPubs,
-      customerDepositFees: Amounts.stringify(tally.customerDepositFees),
-      customerWireFees: Amounts.stringify(tally.customerWireFees),
-    },
-  };
-}
-
 export async function checkPaymentByProposalId(
   ws: InternalWalletState,
   proposalId: string,
@@ -1704,9 +1258,7 @@ export async function confirmPay(
 
   const contractData = d.contractData;
 
-  let selectCoinsResult: SelectPayCoinsResult | undefined = undefined;
-
-  selectCoinsResult = await selectPayCoinsNew(ws, {
+  const selectCoinsResult = await selectPayCoinsNew(ws, {
     auditors: contractData.allowedAuditors,
     exchanges: contractData.allowedExchanges,
     wireMethod: contractData.wireMethod,
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts 
b/packages/taler-wallet-core/src/operations/refresh.ts
index 477a00503..70f0579c0 100644
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -85,10 +85,8 @@ import {
 } from "../util/retries.js";
 import { makeCoinAvailable } from "./common.js";
 import { updateExchangeFromUrl } from "./exchanges.js";
-import {
-  isWithdrawableDenom,
-  selectWithdrawalDenominations,
-} from "./withdraw.js";
+import { selectWithdrawalDenominations } from "../util/coinSelection.js";
+import { isWithdrawableDenom } from "../index.js";
 
 const logger = new Logger("refresh.ts");
 
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts 
b/packages/taler-wallet-core/src/operations/withdraw.ts
index 2c91d4184..643737e93 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -93,7 +93,6 @@ import {
   runLongpollAsync,
   runOperationWithErrorReporting,
 } from "../operations/common.js";
-import { walletCoreDebugFlags } from "../util/debugFlags.js";
 import {
   HttpRequestLibrary,
   HttpResponse,
@@ -123,168 +122,17 @@ import {
   getExchangeTrust,
   updateExchangeFromUrl,
 } from "./exchanges.js";
+import {
+  selectForcedWithdrawalDenominations,
+  selectWithdrawalDenominations,
+} from "../util/coinSelection.js";
+import { isWithdrawableDenom } from "../index.js";
 
 /**
  * Logger for this file.
  */
 const logger = new Logger("operations/withdraw.ts");
 
-/**
- * Check if a denom is withdrawable based on the expiration time,
- * revocation and offered state.
- */
-export function isWithdrawableDenom(d: DenominationRecord): boolean {
-  const now = AbsoluteTime.now();
-  const start = AbsoluteTime.fromTimestamp(d.stampStart);
-  const withdrawExpire = AbsoluteTime.fromTimestamp(d.stampExpireWithdraw);
-  const started = AbsoluteTime.cmp(now, start) >= 0;
-  let lastPossibleWithdraw: AbsoluteTime;
-  if (walletCoreDebugFlags.denomselAllowLate) {
-    lastPossibleWithdraw = start;
-  } else {
-    lastPossibleWithdraw = AbsoluteTime.subtractDuraction(
-      withdrawExpire,
-      durationFromSpec({ minutes: 5 }),
-    );
-  }
-  const remaining = Duration.getRemaining(lastPossibleWithdraw, now);
-  const stillOkay = remaining.d_ms !== 0;
-  return started && stillOkay && !d.isRevoked && d.isOffered;
-}
-
-/**
- * Get a list of denominations (with repetitions possible)
- * whose total value is as close as possible to the available
- * amount, but never larger.
- */
-export function selectWithdrawalDenominations(
-  amountAvailable: AmountJson,
-  denoms: DenominationRecord[],
-): DenomSelectionState {
-  let remaining = Amounts.copy(amountAvailable);
-
-  const selectedDenoms: {
-    count: number;
-    denomPubHash: string;
-  }[] = [];
-
-  let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
-  let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
-
-  denoms = denoms.filter(isWithdrawableDenom);
-  denoms.sort((d1, d2) =>
-    Amounts.cmp(
-      DenominationRecord.getValue(d2),
-      DenominationRecord.getValue(d1),
-    ),
-  );
-
-  for (const d of denoms) {
-    let count = 0;
-    const cost = Amounts.add(
-      DenominationRecord.getValue(d),
-      d.fees.feeWithdraw,
-    ).amount;
-    for (;;) {
-      if (Amounts.cmp(remaining, cost) < 0) {
-        break;
-      }
-      remaining = Amounts.sub(remaining, cost).amount;
-      count++;
-    }
-    if (count > 0) {
-      totalCoinValue = Amounts.add(
-        totalCoinValue,
-        Amounts.mult(DenominationRecord.getValue(d), count).amount,
-      ).amount;
-      totalWithdrawCost = Amounts.add(
-        totalWithdrawCost,
-        Amounts.mult(cost, count).amount,
-      ).amount;
-      selectedDenoms.push({
-        count,
-        denomPubHash: d.denomPubHash,
-      });
-    }
-
-    if (Amounts.isZero(remaining)) {
-      break;
-    }
-  }
-
-  if (logger.shouldLogTrace()) {
-    logger.trace(
-      `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
-    );
-    for (const sd of selectedDenoms) {
-      logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`);
-    }
-    logger.trace("(end of withdrawal denom list)");
-  }
-
-  return {
-    selectedDenoms,
-    totalCoinValue: Amounts.stringify(totalCoinValue),
-    totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
-  };
-}
-
-export function selectForcedWithdrawalDenominations(
-  amountAvailable: AmountJson,
-  denoms: DenominationRecord[],
-  forcedDenomSel: ForcedDenomSel,
-): DenomSelectionState {
-  const selectedDenoms: {
-    count: number;
-    denomPubHash: string;
-  }[] = [];
-
-  let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
-  let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
-
-  denoms = denoms.filter(isWithdrawableDenom);
-  denoms.sort((d1, d2) =>
-    Amounts.cmp(
-      DenominationRecord.getValue(d2),
-      DenominationRecord.getValue(d1),
-    ),
-  );
-
-  for (const fds of forcedDenomSel.denoms) {
-    const count = fds.count;
-    const denom = denoms.find((x) => {
-      return Amounts.cmp(DenominationRecord.getValue(x), fds.value) == 0;
-    });
-    if (!denom) {
-      throw Error(
-        `unable to find denom for forced selection (value ${fds.value})`,
-      );
-    }
-    const cost = Amounts.add(
-      DenominationRecord.getValue(denom),
-      denom.fees.feeWithdraw,
-    ).amount;
-    totalCoinValue = Amounts.add(
-      totalCoinValue,
-      Amounts.mult(DenominationRecord.getValue(denom), count).amount,
-    ).amount;
-    totalWithdrawCost = Amounts.add(
-      totalWithdrawCost,
-      Amounts.mult(cost, count).amount,
-    ).amount;
-    selectedDenoms.push({
-      count,
-      denomPubHash: denom.denomPubHash,
-    });
-  }
-
-  return {
-    selectedDenoms,
-    totalCoinValue: Amounts.stringify(totalCoinValue),
-    totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
-  };
-}
-
 /**
  * Get information about a withdrawal from
  * a taler://withdraw URI by asking the bank.
diff --git a/packages/demobank-ui/src/components/EmptyComponentExample/state.ts 
b/packages/taler-wallet-core/src/util/coinSelection.test.ts
similarity index 69%
copy from packages/demobank-ui/src/components/EmptyComponentExample/state.ts
copy to packages/taler-wallet-core/src/util/coinSelection.test.ts
index e147a7ccf..7814a9233 100644
--- a/packages/demobank-ui/src/components/EmptyComponentExample/state.ts
+++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts
@@ -13,13 +13,17 @@
  You should have received a copy of the GNU General Public License along with
  GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
  */
+import test, { ExecutionContext } from "ava";
 
-// import { wxApi } from "../../wxApi.js";
-import { Props, State } from "./index.js";
-
-export function useComponentState({ p }: Props): State {
+function expect(t: ExecutionContext, thing: any): any {
   return {
-    status: "ready",
-    error: undefined,
+    deep: {
+      equal: (another: any) => t.deepEqual(thing, another),
+      equals: (another: any) => t.deepEqual(thing, another),
+    },
   };
 }
+
+test("should have a test", (t) => {
+  expect(t, true).equal(true);
+});
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts 
b/packages/taler-wallet-core/src/util/coinSelection.ts
index 0bd624bf7..176d636fc 100644
--- a/packages/taler-wallet-core/src/util/coinSelection.ts
+++ b/packages/taler-wallet-core/src/util/coinSelection.ts
@@ -23,13 +23,35 @@
 /**
  * Imports.
  */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
 import {
+  AbsoluteTime,
   AgeCommitmentProof,
+  AgeRestriction,
   AmountJson,
   Amounts,
+  CoinStatus,
+  DenominationInfo,
   DenominationPubKey,
+  DenomSelectionState,
+  ForcedCoinSel,
+  ForcedDenomSel,
+  j2s,
   Logger,
+  parsePaytoUri,
+  PayCoinSelection,
+  PayMerchantInsufficientBalanceDetails,
+  strcmp,
 } from "@gnu-taler/taler-util";
+import {
+  AllowedAuditorInfo,
+  AllowedExchangeInfo,
+  DenominationRecord,
+} from "../db.js";
+import { getExchangeDetails, isWithdrawableDenom } from "../index.js";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import { getMerchantPaymentBalanceDetails } from "../operations/balance.js";
+import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
 
 const logger = new Logger("coinSelection.ts");
 
@@ -125,7 +147,7 @@ export interface CoinSelectionTally {
  * Account for the fees of spending a coin.
  */
 export function tallyFees(
-  tally: CoinSelectionTally,
+  tally: Readonly<CoinSelectionTally>,
   wireFeesPerExchange: Record<string, AmountJson>,
   wireFeeAmortization: number,
   exchangeBaseUrl: string,
@@ -193,3 +215,576 @@ export function tallyFees(
     lastDepositFee: feeDeposit,
   };
 }
+
+export type SelectPayCoinsResult =
+  | {
+      type: "failure";
+      insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
+    }
+  | { type: "success"; coinSel: PayCoinSelection };
+
+/**
+ * Given a list of candidate coins, select coins to spend under the merchant's
+ * constraints.
+ *
+ * The prevPayCoins can be specified to "repair" a coin selection
+ * by adding additional coins, after a broken (e.g. double-spent) coin
+ * has been removed from the selection.
+ *
+ * This function is only exported for the sake of unit tests.
+ */
+export async function selectPayCoinsNew(
+  ws: InternalWalletState,
+  req: SelectPayCoinRequestNg,
+): Promise<SelectPayCoinsResult> {
+  const {
+    contractTermsAmount,
+    depositFeeLimit,
+    wireFeeLimit,
+    wireFeeAmortization,
+  } = req;
+
+  const [candidateDenoms, wireFeesPerExchange] = await selectCandidates(
+    ws,
+    req,
+  );
+
+  const coinPubs: string[] = [];
+  const coinContributions: AmountJson[] = [];
+  const currency = contractTermsAmount.currency;
+
+  let tally: CoinSelectionTally = {
+    amountPayRemaining: contractTermsAmount,
+    amountWireFeeLimitRemaining: wireFeeLimit,
+    amountDepositFeeLimitRemaining: depositFeeLimit,
+    customerDepositFees: Amounts.zeroOfCurrency(currency),
+    customerWireFees: Amounts.zeroOfCurrency(currency),
+    wireFeeCoveredForExchange: new Set(),
+    lastDepositFee: Amounts.zeroOfCurrency(currency),
+  };
+
+  const prevPayCoins = req.prevPayCoins ?? [];
+
+  // Look at existing pay coin selection and tally up
+  for (const prev of prevPayCoins) {
+    tally = tallyFees(
+      tally,
+      wireFeesPerExchange,
+      wireFeeAmortization,
+      prev.exchangeBaseUrl,
+      prev.feeDeposit,
+    );
+    tally.amountPayRemaining = Amounts.sub(
+      tally.amountPayRemaining,
+      prev.contribution,
+    ).amount;
+
+    coinPubs.push(prev.coinPub);
+    coinContributions.push(prev.contribution);
+  }
+
+  let selectedDenom: SelResult | undefined;
+  if (req.forcedSelection) {
+    selectedDenom = selectForced(req, candidateDenoms);
+  } else {
+    // FIXME:  Here, we should select coins in a smarter way.
+    // Instead of always spending the next-largest coin,
+    // we should try to find the smallest coin that covers the
+    // amount.
+    selectedDenom = selectGreedy(
+      req,
+      candidateDenoms,
+      wireFeesPerExchange,
+      tally,
+    );
+  }
+
+  if (!selectedDenom) {
+    const details = await getMerchantPaymentBalanceDetails(ws, {
+      acceptedAuditors: req.auditors,
+      acceptedExchanges: req.exchanges,
+      acceptedWireMethods: [req.wireMethod],
+      currency: Amounts.currencyOf(req.contractTermsAmount),
+      minAge: req.requiredMinimumAge ?? 0,
+    });
+    let feeGapEstimate: AmountJson;
+    if (
+      Amounts.cmp(
+        details.balanceMerchantDepositable,
+        req.contractTermsAmount,
+      ) >= 0
+    ) {
+      // FIXME: We can probably give a better estimate.
+      feeGapEstimate = Amounts.add(
+        tally.amountPayRemaining,
+        tally.lastDepositFee,
+      ).amount;
+    } else {
+      feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount);
+    }
+    return {
+      type: "failure",
+      insufficientBalanceDetails: {
+        amountRequested: Amounts.stringify(req.contractTermsAmount),
+        balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
+        balanceAvailable: Amounts.stringify(details.balanceAvailable),
+        balanceMaterial: Amounts.stringify(details.balanceMaterial),
+        balanceMerchantAcceptable: Amounts.stringify(
+          details.balanceMerchantAcceptable,
+        ),
+        balanceMerchantDepositable: Amounts.stringify(
+          details.balanceMerchantDepositable,
+        ),
+        feeGapEstimate: Amounts.stringify(feeGapEstimate),
+      },
+    };
+  }
+
+  const finalSel = selectedDenom;
+
+  logger.trace(`coin selection request ${j2s(req)}`);
+  logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`);
+
+  await ws.db
+    .mktx((x) => [x.coins, x.denominations])
+    .runReadOnly(async (tx) => {
+      for (const dph of Object.keys(finalSel)) {
+        const selInfo = finalSel[dph];
+        const numRequested = selInfo.contributions.length;
+        const query = [
+          selInfo.exchangeBaseUrl,
+          selInfo.denomPubHash,
+          selInfo.maxAge,
+          CoinStatus.Fresh,
+        ];
+        logger.info(`query: ${j2s(query)}`);
+        const coins =
+          await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
+            query,
+            numRequested,
+          );
+        if (coins.length != numRequested) {
+          throw Error(
+            `coin selection failed (not available anymore, got only 
${coins.length}/${numRequested})`,
+          );
+        }
+        coinPubs.push(...coins.map((x) => x.coinPub));
+        coinContributions.push(...selInfo.contributions);
+      }
+    });
+
+  return {
+    type: "success",
+    coinSel: {
+      paymentAmount: Amounts.stringify(contractTermsAmount),
+      coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
+      coinPubs,
+      customerDepositFees: Amounts.stringify(tally.customerDepositFees),
+      customerWireFees: Amounts.stringify(tally.customerWireFees),
+    },
+  };
+}
+
+function makeAvailabilityKey(
+  exchangeBaseUrl: string,
+  denomPubHash: string,
+  maxAge: number,
+): string {
+  return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
+}
+
+/**
+ * Selection result.
+ */
+interface SelResult {
+  /**
+   * Map from an availability key
+   * to an array of contributions.
+   */
+  [avKey: string]: {
+    exchangeBaseUrl: string;
+    denomPubHash: string;
+    maxAge: number;
+    contributions: AmountJson[];
+  };
+}
+
+function selectGreedy(
+  req: SelectPayCoinRequestNg,
+  candidateDenoms: AvailableDenom[],
+  wireFeesPerExchange: Record<string, AmountJson>,
+  tally: CoinSelectionTally,
+): SelResult | undefined {
+  const { wireFeeAmortization } = req;
+  const selectedDenom: SelResult = {};
+  for (const denom of candidateDenoms) {
+    const contributions: AmountJson[] = [];
+
+    // Don't use this coin if depositing it is more expensive than
+    // the amount it would give the merchant.
+    if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) {
+      tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit);
+      continue;
+    }
+
+    for (
+      let i = 0;
+      i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining);
+      i++
+    ) {
+      tally = tallyFees(
+        tally,
+        wireFeesPerExchange,
+        wireFeeAmortization,
+        denom.exchangeBaseUrl,
+        Amounts.parseOrThrow(denom.feeDeposit),
+      );
+
+      const coinSpend = Amounts.max(
+        Amounts.min(tally.amountPayRemaining, denom.value),
+        denom.feeDeposit,
+      );
+
+      tally.amountPayRemaining = Amounts.sub(
+        tally.amountPayRemaining,
+        coinSpend,
+      ).amount;
+
+      contributions.push(coinSpend);
+    }
+
+    if (contributions.length) {
+      const avKey = makeAvailabilityKey(
+        denom.exchangeBaseUrl,
+        denom.denomPubHash,
+        denom.maxAge,
+      );
+      let sd = selectedDenom[avKey];
+      if (!sd) {
+        sd = {
+          contributions: [],
+          denomPubHash: denom.denomPubHash,
+          exchangeBaseUrl: denom.exchangeBaseUrl,
+          maxAge: denom.maxAge,
+        };
+      }
+      sd.contributions.push(...contributions);
+      selectedDenom[avKey] = sd;
+    }
+  }
+  return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined;
+}
+
+function selectForced(
+  req: SelectPayCoinRequestNg,
+  candidateDenoms: AvailableDenom[],
+): SelResult | undefined {
+  const selectedDenom: SelResult = {};
+
+  const forcedSelection = req.forcedSelection;
+  checkLogicInvariant(!!forcedSelection);
+
+  for (const forcedCoin of forcedSelection.coins) {
+    let found = false;
+    for (const aci of candidateDenoms) {
+      if (aci.numAvailable <= 0) {
+        continue;
+      }
+      if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
+        aci.numAvailable--;
+        const avKey = makeAvailabilityKey(
+          aci.exchangeBaseUrl,
+          aci.denomPubHash,
+          aci.maxAge,
+        );
+        let sd = selectedDenom[avKey];
+        if (!sd) {
+          sd = {
+            contributions: [],
+            denomPubHash: aci.denomPubHash,
+            exchangeBaseUrl: aci.exchangeBaseUrl,
+            maxAge: aci.maxAge,
+          };
+        }
+        sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
+        selectedDenom[avKey] = sd;
+        found = true;
+        break;
+      }
+    }
+    if (!found) {
+      throw Error("can't find coin for forced coin selection");
+    }
+  }
+
+  return selectedDenom;
+}
+
+export interface SelectPayCoinRequestNg {
+  exchanges: AllowedExchangeInfo[];
+  auditors: AllowedAuditorInfo[];
+  wireMethod: string;
+  contractTermsAmount: AmountJson;
+  depositFeeLimit: AmountJson;
+  wireFeeLimit: AmountJson;
+  wireFeeAmortization: number;
+  prevPayCoins?: PreviousPayCoins;
+  requiredMinimumAge?: number;
+  forcedSelection?: ForcedCoinSel;
+}
+
+export type AvailableDenom = DenominationInfo & {
+  maxAge: number;
+  numAvailable: number;
+};
+
+export async function selectCandidates(
+  ws: InternalWalletState,
+  req: SelectPayCoinRequestNg,
+): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+  return await ws.db
+    .mktx((x) => [
+      x.exchanges,
+      x.exchangeDetails,
+      x.denominations,
+      x.coinAvailability,
+    ])
+    .runReadOnly(async (tx) => {
+      // FIXME: Use the existing helper (from balance.ts) to
+      // get acceptable exchanges.
+      const denoms: AvailableDenom[] = [];
+      const exchanges = await tx.exchanges.iter().toArray();
+      const wfPerExchange: Record<string, AmountJson> = {};
+      for (const exchange of exchanges) {
+        const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
+        // 1.- exchange has same currency
+        if (exchangeDetails?.currency !== req.contractTermsAmount.currency) {
+          continue;
+        }
+        let wireMethodFee: string | undefined;
+        // 2.- exchange supports wire method
+        for (const acc of exchangeDetails.wireInfo.accounts) {
+          const pp = parsePaytoUri(acc.payto_uri);
+          checkLogicInvariant(!!pp);
+          if (pp.targetType === req.wireMethod) {
+            // also check that wire method is supported now
+            const wireFeeStr = exchangeDetails.wireInfo.feesForType[
+              req.wireMethod
+            ]?.find((x) => {
+              return AbsoluteTime.isBetween(
+                AbsoluteTime.now(),
+                AbsoluteTime.fromTimestamp(x.startStamp),
+                AbsoluteTime.fromTimestamp(x.endStamp),
+              );
+            })?.wireFee;
+            if (wireFeeStr) {
+              wireMethodFee = wireFeeStr;
+            }
+            break;
+          }
+        }
+        if (!wireMethodFee) {
+          break;
+        }
+        wfPerExchange[exchange.baseUrl] = Amounts.parseOrThrow(wireMethodFee);
+        // 3.- exchange is trusted in the exchange list or auditor list
+        let accepted = false;
+        for (const allowedExchange of req.exchanges) {
+          if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) 
{
+            accepted = true;
+            break;
+          }
+        }
+        for (const allowedAuditor of req.auditors) {
+          for (const providedAuditor of exchangeDetails.auditors) {
+            if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
+              accepted = true;
+              break;
+            }
+          }
+        }
+        if (!accepted) {
+          continue;
+        }
+        //4.- filter coins restricted by age
+        let ageLower = 0;
+        let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+        if (req.requiredMinimumAge) {
+          ageLower = req.requiredMinimumAge;
+        }
+        const myExchangeCoins =
+          await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+            GlobalIDB.KeyRange.bound(
+              [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+              [
+                exchangeDetails.exchangeBaseUrl,
+                ageUpper,
+                Number.MAX_SAFE_INTEGER,
+              ],
+            ),
+          );
+        //5.- save denoms with how many coins are available
+        // FIXME: Check that the individual denomination is audited!
+        // FIXME: Should we exclude denominations that are
+        // not spendable anymore?
+        for (const coinAvail of myExchangeCoins) {
+          const denom = await tx.denominations.get([
+            coinAvail.exchangeBaseUrl,
+            coinAvail.denomPubHash,
+          ]);
+          checkDbInvariant(!!denom);
+          if (denom.isRevoked || !denom.isOffered) {
+            continue;
+          }
+          denoms.push({
+            ...DenominationRecord.toDenomInfo(denom),
+            numAvailable: coinAvail.freshCoinCount ?? 0,
+            maxAge: coinAvail.maxAge,
+          });
+        }
+      }
+      // Sort by available amount (descending),  deposit fee (ascending) and
+      // denomPub (ascending) if deposit fee is the same
+      // (to guarantee deterministic results)
+      denoms.sort(
+        (o1, o2) =>
+          -Amounts.cmp(o1.value, o2.value) ||
+          Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+          strcmp(o1.denomPubHash, o2.denomPubHash),
+      );
+      return [denoms, wfPerExchange];
+    });
+}
+
+/**
+ * Get a list of denominations (with repetitions possible)
+ * whose total value is as close as possible to the available
+ * amount, but never larger.
+ */
+export function selectWithdrawalDenominations(
+  amountAvailable: AmountJson,
+  denoms: DenominationRecord[],
+): DenomSelectionState {
+  let remaining = Amounts.copy(amountAvailable);
+
+  const selectedDenoms: {
+    count: number;
+    denomPubHash: string;
+  }[] = [];
+
+  let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+  let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+
+  denoms = denoms.filter(isWithdrawableDenom);
+  denoms.sort((d1, d2) =>
+    Amounts.cmp(
+      DenominationRecord.getValue(d2),
+      DenominationRecord.getValue(d1),
+    ),
+  );
+
+  for (const d of denoms) {
+    let count = 0;
+    const cost = Amounts.add(
+      DenominationRecord.getValue(d),
+      d.fees.feeWithdraw,
+    ).amount;
+    for (;;) {
+      if (Amounts.cmp(remaining, cost) < 0) {
+        break;
+      }
+      remaining = Amounts.sub(remaining, cost).amount;
+      count++;
+    }
+    if (count > 0) {
+      totalCoinValue = Amounts.add(
+        totalCoinValue,
+        Amounts.mult(DenominationRecord.getValue(d), count).amount,
+      ).amount;
+      totalWithdrawCost = Amounts.add(
+        totalWithdrawCost,
+        Amounts.mult(cost, count).amount,
+      ).amount;
+      selectedDenoms.push({
+        count,
+        denomPubHash: d.denomPubHash,
+      });
+    }
+
+    if (Amounts.isZero(remaining)) {
+      break;
+    }
+  }
+
+  if (logger.shouldLogTrace()) {
+    logger.trace(
+      `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
+    );
+    for (const sd of selectedDenoms) {
+      logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`);
+    }
+    logger.trace("(end of withdrawal denom list)");
+  }
+
+  return {
+    selectedDenoms,
+    totalCoinValue: Amounts.stringify(totalCoinValue),
+    totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+  };
+}
+
+export function selectForcedWithdrawalDenominations(
+  amountAvailable: AmountJson,
+  denoms: DenominationRecord[],
+  forcedDenomSel: ForcedDenomSel,
+): DenomSelectionState {
+  const selectedDenoms: {
+    count: number;
+    denomPubHash: string;
+  }[] = [];
+
+  let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+  let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+
+  denoms = denoms.filter(isWithdrawableDenom);
+  denoms.sort((d1, d2) =>
+    Amounts.cmp(
+      DenominationRecord.getValue(d2),
+      DenominationRecord.getValue(d1),
+    ),
+  );
+
+  for (const fds of forcedDenomSel.denoms) {
+    const count = fds.count;
+    const denom = denoms.find((x) => {
+      return Amounts.cmp(DenominationRecord.getValue(x), fds.value) == 0;
+    });
+    if (!denom) {
+      throw Error(
+        `unable to find denom for forced selection (value ${fds.value})`,
+      );
+    }
+    const cost = Amounts.add(
+      DenominationRecord.getValue(denom),
+      denom.fees.feeWithdraw,
+    ).amount;
+    totalCoinValue = Amounts.add(
+      totalCoinValue,
+      Amounts.mult(DenominationRecord.getValue(denom), count).amount,
+    ).amount;
+    totalWithdrawCost = Amounts.add(
+      totalWithdrawCost,
+      Amounts.mult(cost, count).amount,
+    ).amount;
+    selectedDenoms.push({
+      count,
+      denomPubHash: denom.denomPubHash,
+    });
+  }
+
+  return {
+    selectedDenoms,
+    totalCoinValue: Amounts.stringify(totalCoinValue),
+    totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+  };
+}
diff --git a/packages/taler-wallet-core/src/util/denominations.ts 
b/packages/taler-wallet-core/src/util/denominations.ts
index ef35fe198..fb766e96a 100644
--- a/packages/taler-wallet-core/src/util/denominations.ts
+++ b/packages/taler-wallet-core/src/util/denominations.ts
@@ -20,12 +20,16 @@ import {
   Amounts,
   AmountString,
   DenominationInfo,
+  Duration,
+  durationFromSpec,
   FeeDescription,
   FeeDescriptionPair,
   TalerProtocolTimestamp,
   TimePoint,
   WireFee,
 } from "@gnu-taler/taler-util";
+import { DenominationRecord } from "../db.js";
+import { walletCoreDebugFlags } from "./debugFlags.js";
 
 /**
  * Given a list of denominations with the same value and same period of time:
@@ -443,3 +447,26 @@ export function createTimeline<Type extends object>(
     return result;
   }, [] as FeeDescription[]);
 }
+
+/**
+ * Check if a denom is withdrawable based on the expiration time,
+ * revocation and offered state.
+ */
+export function isWithdrawableDenom(d: DenominationRecord): boolean {
+  const now = AbsoluteTime.now();
+  const start = AbsoluteTime.fromTimestamp(d.stampStart);
+  const withdrawExpire = AbsoluteTime.fromTimestamp(d.stampExpireWithdraw);
+  const started = AbsoluteTime.cmp(now, start) >= 0;
+  let lastPossibleWithdraw: AbsoluteTime;
+  if (walletCoreDebugFlags.denomselAllowLate) {
+    lastPossibleWithdraw = start;
+  } else {
+    lastPossibleWithdraw = AbsoluteTime.subtractDuraction(
+      withdrawExpire,
+      durationFromSpec({ minutes: 5 }),
+    );
+  }
+  const remaining = Duration.getRemaining(lastPossibleWithdraw, now);
+  const stillOkay = remaining.d_ms !== 0;
+  return started && stillOkay && !d.isRevoked && d.isOffered;
+}
diff --git 
a/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts 
b/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts
index 50e71c144..d8bc7d980 100644
--- a/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts
+++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts
@@ -23,6 +23,7 @@ import { WalletApiOperation } from 
"@gnu-taler/taler-wallet-core";
 import { useState } from "preact/hooks";
 import { alertFromError, useAlertContext } from "../../context/alert.js";
 import { useBackendContext } from "../../context/backend.js";
+import { useDevContext } from "../../context/devContext.js";
 import { useTranslationContext } from "../../context/translation.js";
 import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
 import { AccountByType, Props, State } from "./index.js";
@@ -38,6 +39,7 @@ export function useComponentState({
   const hook = useAsyncAsHook(() =>
     api.wallet.call(WalletApiOperation.ListKnownBankAccounts, { currency }),
   );
+  const { devMode } = useDevContext();
 
   const [payto, setPayto] = useState("");
   const [alias, setAlias] = useState("");
@@ -59,9 +61,12 @@ export function useComponentState({
   const accountType: Record<string, string> = {
     "": "Choose one account type",
     iban: "IBAN",
-    // bitcoin: "Bitcoin",
     // "x-taler-bank": "Taler Bank",
   };
+  if (devMode) {
+    accountType["bitcoin"] = "Bitcoin";
+    accountType["x-taler-bank"] = "Taler Bank";
+  }
   const uri = parsePaytoUri(payto);
   const found =
     hook.response.accounts.findIndex(
diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx 
b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
index a9683f680..d95b10297 100644
--- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
@@ -89,7 +89,7 @@ export function TransactionPage({
 
   useEffect(() =>
     api.listener.onUpdateNotification(
-      [NotificationType.WithdrawGroupFinished],
+      [NotificationType.WithdrawGroupFinished, NotificationType.KycRequested],
       state?.retry,
     ),
   );

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]