diff options
| author | Florian Dold <florian@dold.me> | 2022-09-14 20:34:37 +0200 | 
|---|---|---|
| committer | Florian Dold <florian@dold.me> | 2022-09-14 20:40:38 +0200 | 
| commit | c021876b41bff11ad28c3a43808795fa0d02ce99 (patch) | |
| tree | c92f4e83def462ddb0d446c9c476fd32f648d744 /packages | |
| parent | 9d044058e267e9e94f3ee63809a1e22426151ee5 (diff) | |
wallet-core: cache fresh coin count in DB
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/taler-util/src/walletTypes.ts | 1 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/db.ts | 17 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/operations/backup/import.ts | 1 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/operations/deposits.ts | 20 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/operations/pay.ts | 77 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/operations/peer-to-peer.ts | 46 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/operations/refresh.ts | 23 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/operations/tip.ts | 6 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/operations/withdraw.ts | 7 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/util/query.ts | 5 | ||||
| -rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 123 | 
11 files changed, 197 insertions, 129 deletions
| diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index 437ac2964..701049c26 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -529,6 +529,7 @@ export interface PlanchetCreationRequest {  export enum RefreshReason {    Manual = "manual",    PayMerchant = "pay-merchant", +  PayDeposit = "pay-deposit",    PayPeerPush = "pay-peer-push",    PayPeerPull = "pay-peer-pull",    Refund = "refund", diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index da566ff24..6dfa63c06 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -314,6 +314,11 @@ export interface DenominationRecord {     * that includes this denomination.     */    listIssueDate: TalerProtocolTimestamp; + +  /** +   * Number of fresh coins of this denomination that are available. +   */ +  freshCoinCount?: number;  }  /** @@ -520,6 +525,13 @@ export enum CoinStatus {     * Withdrawn and never shown to anybody.     */    Fresh = "fresh", + +  /** +   * Fresh, but currently marked as "suspended", thus won't be used +   * for spending.  Used for testing. +   */ +  FreshSuspended = "fresh-suspended", +    /**     * A coin that has been spent and refreshed.     */ @@ -606,11 +618,6 @@ export interface CoinRecord {    exchangeBaseUrl: string;    /** -   * The coin is currently suspended, and will not be used for payments. -   */ -  suspended: boolean; - -  /**     * Blinding key used when withdrawing the coin.     * Potentionally used again during payback.     */ diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 8f5d019d4..53e45918e 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -413,7 +413,6 @@ export async function importBackup(                  currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),                  denomSig: backupCoin.denom_sig,                  coinPub: compCoin.coinPub, -                suspended: false,                  exchangeBaseUrl: backupExchangeDetails.base_url,                  denomPubHash,                  status: backupCoin.fresh diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 5838be765..6d63def59 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -33,12 +33,11 @@ import {    getRandomBytes,    hashWire,    Logger, -  NotificationType,    parsePaytoUri,    PayCoinSelection,    PrepareDepositRequest,    PrepareDepositResponse, -  TalerErrorDetail, +  RefreshReason,    TalerProtocolTimestamp,    TrackDepositGroupRequest,    TrackDepositGroupResponse, @@ -46,18 +45,15 @@ import {  } from "@gnu-taler/taler-util";  import {    DepositGroupRecord, -  OperationAttemptErrorResult,    OperationAttemptResult,    OperationStatus,  } from "../db.js";  import { InternalWalletState } from "../internal-wallet-state.js";  import { selectPayCoins } from "../util/coinSelection.js";  import { readSuccessResponseJsonOrThrow } from "../util/http.js"; -import { RetryInfo } from "../util/retries.js"; -import { guardOperationException } from "./common.js"; +import { spendCoins } from "../wallet.js";  import { getExchangeDetails } from "./exchanges.js";  import { -  applyCoinSpend,    CoinSelectionRequest,    extractContractData,    generateDepositPermissions, @@ -525,12 +521,12 @@ export async function createDepositGroup(        x.refreshGroups,      ])      .runReadWrite(async (tx) => { -      await applyCoinSpend( -        ws, -        tx, -        payCoinSel, -        `deposit-group:${depositGroup.depositGroupId}`, -      ); +      await spendCoins(ws, tx, { +        allocationId: `deposit-group:${depositGroup.depositGroupId}`, +        coinPubs: payCoinSel.coinPubs, +        contributions: payCoinSel.coinContributions, +        refreshReason: RefreshReason.PayDeposit, +      });        await tx.depositGroups.put(depositGroup);      }); diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index 322e90487..bd7b1f7f0 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -100,6 +100,7 @@ import {  } from "../util/http.js";  import { GetReadWriteAccess } from "../util/query.js";  import { RetryInfo, RetryTags, scheduleRetry } from "../util/retries.js"; +import { spendCoins } from "../wallet.js";  import { getExchangeDetails } from "./exchanges.js";  import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; @@ -156,9 +157,6 @@ export async function getTotalPaymentCost(  }  function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean { -  if (coin.suspended) { -    return false; -  }    if (denom.isRevoked) {      return false;    } @@ -348,65 +346,6 @@ export async function getCandidatePayCoins(  }  /** - * Apply a coin selection to the database.  Marks coins as spent - * and creates a refresh session for the remaining amount. - * - * FIXME:  This does not deal well with conflicting spends! - * When two payments are made in parallel, the same coin can be selected - * for two payments. - * However, this is a situation that can also happen via sync. - */ -export async function applyCoinSpend( -  ws: InternalWalletState, -  tx: GetReadWriteAccess<{ -    coins: typeof WalletStoresV1.coins; -    refreshGroups: typeof WalletStoresV1.refreshGroups; -    denominations: typeof WalletStoresV1.denominations; -  }>, -  coinSelection: PayCoinSelection, -  allocationId: string, -): Promise<void> { -  logger.info(`applying coin spend ${j2s(coinSelection)}`); -  for (let i = 0; i < coinSelection.coinPubs.length; i++) { -    const coin = await tx.coins.get(coinSelection.coinPubs[i]); -    if (!coin) { -      throw Error("coin allocated for payment doesn't exist anymore"); -    } -    const contrib = coinSelection.coinContributions[i]; -    if (coin.status !== CoinStatus.Fresh) { -      const alloc = coin.allocation; -      if (!alloc) { -        continue; -      } -      if (alloc.id !== allocationId) { -        // FIXME: assign error code -        throw Error("conflicting coin allocation (id)"); -      } -      if (0 !== Amounts.cmp(alloc.amount, contrib)) { -        // FIXME: assign error code -        throw Error("conflicting coin allocation (contrib)"); -      } -      continue; -    } -    coin.status = CoinStatus.Dormant; -    coin.allocation = { -      id: allocationId, -      amount: Amounts.stringify(contrib), -    }; -    const remaining = Amounts.sub(coin.currentAmount, contrib); -    if (remaining.saturated) { -      throw Error("not enough remaining balance on coin for payment"); -    } -    coin.currentAmount = remaining.amount; -    await tx.coins.put(coin); -  } -  const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({ -    coinPub: x, -  })); -  await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant); -} - -/**   * Record all information that is necessary to   * pay for a proposal in the wallet's database.   */ @@ -468,7 +407,12 @@ async function recordConfirmPay(          await tx.proposals.put(p);        }        await tx.purchases.put(t); -      await applyCoinSpend(ws, tx, coinSelection, `proposal:${t.proposalId}`); +      await spendCoins(ws, tx, { +        allocationId: `proposal:${t.proposalId}`, +        coinPubs: coinSelection.coinPubs, +        contributions: coinSelection.coinContributions, +        refreshReason: RefreshReason.PayMerchant, +      });      });    ws.notify({ @@ -1038,7 +982,12 @@ async function handleInsufficientFunds(        p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));        p.coinDepositPermissions = undefined;        await tx.purchases.put(p); -      await applyCoinSpend(ws, tx, res, `proposal:${p.proposalId}`); +      await spendCoins(ws, tx, { +        allocationId: `proposal:${p.proposalId}`, +        coinPubs: p.payCoinSelection.coinPubs, +        contributions: p.payCoinSelection.coinContributions, +        refreshReason: RefreshReason.PayMerchant, +      });      });  } diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts b/packages/taler-wallet-core/src/operations/peer-to-peer.ts index 59dad3d55..449a91c68 100644 --- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts +++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts @@ -75,6 +75,8 @@ import { internalCreateWithdrawalGroup } from "./withdraw.js";  import { GetReadOnlyAccess } from "../util/query.js";  import { createRefreshGroup } from "./refresh.js";  import { updateExchangeFromUrl } from "./exchanges.js"; +import { spendCoins } from "../wallet.js"; +import { RetryTags } from "../util/retries.js";  const logger = new Logger("operations/peer-to-peer.ts"); @@ -256,18 +258,14 @@ export async function initiatePeerToPeerPush(          return undefined;        } -      const pubs: CoinPublicKey[] = []; -      for (const c of sel.coins) { -        const coin = await tx.coins.get(c.coinPub); -        checkDbInvariant(!!coin); -        coin.currentAmount = Amounts.sub( -          coin.currentAmount, -          Amounts.parseOrThrow(c.contribution), -        ).amount; -        coin.status = CoinStatus.Dormant; -        pubs.push({ coinPub: coin.coinPub }); -        await tx.coins.put(coin); -      } +      await spendCoins(ws, tx, { +        allocationId: `peer-push:${pursePair.pub}`, +        coinPubs: sel.coins.map((x) => x.coinPub), +        contributions: sel.coins.map((x) => +          Amounts.parseOrThrow(x.contribution), +        ), +        refreshReason: RefreshReason.PayPeerPush, +      });        await tx.peerPushPaymentInitiations.add({          amount: Amounts.stringify(instructedAmount), @@ -284,8 +282,6 @@ export async function initiatePeerToPeerPush(          timestampCreated: TalerProtocolTimestamp.now(),        }); -      await createRefreshGroup(ws, tx, pubs, RefreshReason.PayPeerPush); -        return sel;      });    logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`); @@ -588,20 +584,14 @@ export async function acceptPeerPullPayment(          return undefined;        } -      const pubs: CoinPublicKey[] = []; -      for (const c of sel.coins) { -        const coin = await tx.coins.get(c.coinPub); -        checkDbInvariant(!!coin); -        coin.currentAmount = Amounts.sub( -          coin.currentAmount, -          Amounts.parseOrThrow(c.contribution), -        ).amount; -        coin.status = CoinStatus.Dormant; -        pubs.push({ coinPub: coin.coinPub }); -        await tx.coins.put(coin); -      } - -      await createRefreshGroup(ws, tx, pubs, RefreshReason.PayPeerPull); +      await spendCoins(ws, tx, { +        allocationId: `peer-pull:${req.peerPullPaymentIncomingId}`, +        coinPubs: sel.coins.map((x) => x.coinPub), +        contributions: sel.coins.map((x) => +          Amounts.parseOrThrow(x.contribution), +        ), +        refreshReason: RefreshReason.PayPeerPull, +      });        const pi = await tx.peerPullPaymentIncoming.get(          req.peerPullPaymentIncomingId, diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 719093bd8..d1c366cd0 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -77,6 +77,7 @@ import {  import { checkDbInvariant } from "../util/invariants.js";  import { GetReadWriteAccess } from "../util/query.js";  import { RetryInfo, runOperationHandlerForResult } from "../util/retries.js"; +import { makeCoinAvailable } from "../wallet.js";  import { guardOperationException } from "./common.js";  import { updateExchangeFromUrl } from "./exchanges.js";  import { @@ -670,7 +671,6 @@ async function refreshReveal(            type: CoinSourceType.Refresh,            oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],          }, -        suspended: false,          coinEvHash: pc.coinEvHash,          ageCommitmentProof: pc.ageCommitmentProof,        }; @@ -680,7 +680,7 @@ async function refreshReveal(    }    await ws.db -    .mktx((x) => [x.coins, x.refreshGroups]) +    .mktx((x) => [x.coins, x.denominations, x.refreshGroups])      .runReadWrite(async (tx) => {        const rg = await tx.refreshGroups.get(refreshGroupId);        if (!rg) { @@ -694,7 +694,7 @@ async function refreshReveal(        rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;        updateGroupStatus(rg);        for (const coin of coins) { -        await tx.coins.put(coin); +        await makeCoinAvailable(ws, tx, coin);        }        await tx.refreshGroups.put(rg);      }); @@ -865,10 +865,22 @@ export async function createRefreshGroup(        !!denom,        "denomination for existing coin must be in database",      ); +    if (coin.status !== CoinStatus.Dormant) { +      coin.status = CoinStatus.Dormant; +      const denom = await tx.denominations.get([ +        coin.exchangeBaseUrl, +        coin.denomPubHash, +      ]); +      checkDbInvariant(!!denom); +      checkDbInvariant( +        denom.freshCoinCount != null && denom.freshCoinCount > 0, +      ); +      denom.freshCoinCount--; +      await tx.denominations.put(denom); +    }      const refreshAmount = coin.currentAmount;      inputPerCoin.push(refreshAmount);      coin.currentAmount = Amounts.getZero(refreshAmount.currency); -    coin.status = CoinStatus.Dormant;      await tx.coins.put(coin);      const denoms = await getDenoms(coin.exchangeBaseUrl);      const cost = getTotalRefreshCost(denoms, denom, refreshAmount); @@ -965,9 +977,6 @@ export async function autoRefresh(          if (coin.status !== CoinStatus.Fresh) {            continue;          } -        if (coin.suspended) { -          continue; -        }          const denom = await tx.denominations.get([            exchangeBaseUrl,            coin.denomPubHash, diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts index 04da2b988..f70e2d02b 100644 --- a/packages/taler-wallet-core/src/operations/tip.ts +++ b/packages/taler-wallet-core/src/operations/tip.ts @@ -51,6 +51,7 @@ import {    readSuccessResponseJsonOrThrow,  } from "../util/http.js";  import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; +import { makeCoinAvailable } from "../wallet.js";  import { updateExchangeFromUrl } from "./exchanges.js";  import {    getCandidateWithdrawalDenoms, @@ -310,13 +311,12 @@ export async function processTip(        denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig },        exchangeBaseUrl: tipRecord.exchangeBaseUrl,        status: CoinStatus.Fresh, -      suspended: false,        coinEvHash: planchet.coinEvHash,      });    }    await ws.db -    .mktx((x) => [x.coins, x.tips, x.withdrawalGroups]) +    .mktx((x) => [x.coins, x.denominations, x.tips])      .runReadWrite(async (tx) => {        const tr = await tx.tips.get(walletTipId);        if (!tr) { @@ -328,7 +328,7 @@ export async function processTip(        tr.pickedUpTimestamp = TalerProtocolTimestamp.now();        await tx.tips.put(tr);        for (const cr of newCoinRecords) { -        await tx.coins.put(cr); +        await makeCoinAvailable(ws, tx, cr);        }      }); diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 1b8383776..bee83265c 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -93,11 +93,11 @@ import {  } from "../util/http.js";  import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";  import { DbAccess, GetReadOnlyAccess } from "../util/query.js"; -import { RetryInfo } from "../util/retries.js";  import {    WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,    WALLET_EXCHANGE_PROTOCOL_VERSION,  } from "../versions.js"; +import { makeCoinAvailable } from "../wallet.js";  import {    getExchangeDetails,    getExchangePaytoUri, @@ -805,7 +805,6 @@ async function processPlanchetVerifyAndStoreCoin(        reservePub: planchet.reservePub,        withdrawalGroupId: withdrawalGroup.withdrawalGroupId,      }, -    suspended: false,      ageCommitmentProof: planchet.ageCommitmentProof,    }; @@ -815,7 +814,7 @@ async function processPlanchetVerifyAndStoreCoin(    // withdrawal succeeded.  If so, mark the withdrawal    // group as finished.    const firstSuccess = await ws.db -    .mktx((x) => [x.coins, x.withdrawalGroups, x.planchets]) +    .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups, x.planchets])      .runReadWrite(async (tx) => {        const p = await tx.planchets.get(planchetCoinPub);        if (!p || p.withdrawalDone) { @@ -823,7 +822,7 @@ async function processPlanchetVerifyAndStoreCoin(        }        p.withdrawalDone = true;        await tx.planchets.put(p); -      await tx.coins.add(coin); +      await makeCoinAvailable(ws, tx, coin);        return true;      }); diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts index 025959253..17b713659 100644 --- a/packages/taler-wallet-core/src/util/query.ts +++ b/packages/taler-wallet-core/src/util/query.ts @@ -445,14 +445,15 @@ function runTx<Arg, Res>(        if (!gotFunResult) {          const msg =            "BUG: transaction closed before transaction function returned"; -        console.error(msg); +        logger.error(msg); +        logger.error(`${stack.stack}`);          reject(Error(msg));        }        resolve(funResult);      };      tx.onerror = () => {        logger.error("error in transaction"); -      logger.error(`${stack}`); +      logger.error(`${stack.stack}`);      };      tx.onabort = () => {        let msg: string; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 0e7772259..afbee4e64 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -99,7 +99,9 @@ import {  } from "./crypto/workers/cryptoDispatcher.js";  import {    AuditorTrustRecord, +  CoinRecord,    CoinSourceType, +  CoinStatus,    exportDb,    importDb,    OperationAttemptResult, @@ -216,6 +218,7 @@ import {    HttpRequestLibrary,    readSuccessResponseJsonOrThrow,  } from "./util/http.js"; +import { checkDbInvariant } from "./util/invariants.js";  import {    AsyncCondition,    OpenedPromise, @@ -787,21 +790,135 @@ async function getExchangeDetailedInfo(    };  } +export async function makeCoinAvailable( +  ws: InternalWalletState, +  tx: GetReadWriteAccess<{ +    coins: typeof WalletStoresV1.coins; +    denominations: typeof WalletStoresV1.denominations; +  }>, +  coinRecord: CoinRecord, +): Promise<void> { +  const denom = await tx.denominations.get([ +    coinRecord.exchangeBaseUrl, +    coinRecord.denomPubHash, +  ]); +  checkDbInvariant(!!denom); +  if (!denom.freshCoinCount) { +    denom.freshCoinCount = 0; +  } +  denom.freshCoinCount++; +  await tx.coins.put(coinRecord); +  await tx.denominations.put(denom); +} + +export interface CoinsSpendInfo { +  coinPubs: string[]; +  contributions: AmountJson[]; +  refreshReason: RefreshReason; +  /** +   * Identifier for what the coin has been spent for. +   */ +  allocationId: string; +} + +export async function spendCoins( +  ws: InternalWalletState, +  tx: GetReadWriteAccess<{ +    coins: typeof WalletStoresV1.coins; +    refreshGroups: typeof WalletStoresV1.refreshGroups; +    denominations: typeof WalletStoresV1.denominations; +  }>, +  csi: CoinsSpendInfo, +): Promise<void> { +  for (let i = 0; i < csi.coinPubs.length; i++) { +    const coin = await tx.coins.get(csi.coinPubs[i]); +    if (!coin) { +      throw Error("coin allocated for payment doesn't exist anymore"); +    } +    const denom = await tx.denominations.get([ +      coin.exchangeBaseUrl, +      coin.denomPubHash, +    ]); +    checkDbInvariant(!!denom); +    const contrib = csi.contributions[i]; +    if (coin.status !== CoinStatus.Fresh) { +      const alloc = coin.allocation; +      if (!alloc) { +        continue; +      } +      if (alloc.id !== csi.allocationId) { +        // FIXME: assign error code +        throw Error("conflicting coin allocation (id)"); +      } +      if (0 !== Amounts.cmp(alloc.amount, contrib)) { +        // FIXME: assign error code +        throw Error("conflicting coin allocation (contrib)"); +      } +      continue; +    } +    coin.status = CoinStatus.Dormant; +    coin.allocation = { +      id: csi.allocationId, +      amount: Amounts.stringify(contrib), +    }; +    const remaining = Amounts.sub(coin.currentAmount, contrib); +    if (remaining.saturated) { +      throw Error("not enough remaining balance on coin for payment"); +    } +    coin.currentAmount = remaining.amount; +    checkDbInvariant(!!denom); +    if (denom.freshCoinCount == null || denom.freshCoinCount === 0) { +      throw Error(`invalid coin count ${denom.freshCoinCount} in DB`); +    } +    denom.freshCoinCount--; +    await tx.coins.put(coin); +    await tx.denominations.put(denom); +  } +  const refreshCoinPubs = csi.coinPubs.map((x) => ({ +    coinPub: x, +  })); +  await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant); +} +  async function setCoinSuspended(    ws: InternalWalletState,    coinPub: string,    suspended: boolean,  ): Promise<void> {    await ws.db -    .mktx((x) => [x.coins]) +    .mktx((x) => [x.coins, x.denominations])      .runReadWrite(async (tx) => {        const c = await tx.coins.get(coinPub);        if (!c) {          logger.warn(`coin ${coinPub} not found, won't suspend`);          return;        } -      c.suspended = suspended; +      const denom = await tx.denominations.get([ +        c.exchangeBaseUrl, +        c.denomPubHash, +      ]); +      checkDbInvariant(!!denom); +      if (suspended) { +        if (c.status !== CoinStatus.Fresh) { +          return; +        } +        if (denom.freshCoinCount == null || denom.freshCoinCount === 0) { +          throw Error(`invalid coin count ${denom.freshCoinCount} in DB`); +        } +        denom.freshCoinCount--; +        c.status = CoinStatus.FreshSuspended; +      } else { +        if (c.status == CoinStatus.Dormant) { +          return; +        } +        if (denom.freshCoinCount == null) { +          denom.freshCoinCount = 0; +        } +        denom.freshCoinCount++; +        c.status = CoinStatus.Fresh; +      }        await tx.coins.put(c); +      await tx.denominations.put(denom);      });  } @@ -857,7 +974,7 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {            refresh_parent_coin_pub: refreshParentCoinPub,            remaining_value: Amounts.stringify(c.currentAmount),            withdrawal_reserve_pub: withdrawalReservePub, -          coin_suspended: c.suspended, +          coin_suspended: c.status === CoinStatus.FreshSuspended,            ageCommitmentProof: c.ageCommitmentProof,          });        } | 
