diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/refresh.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/refresh.ts | 1449 |
1 files changed, 0 insertions, 1449 deletions
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts deleted file mode 100644 index 17ac54cfb..000000000 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ /dev/null @@ -1,1449 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - 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 { - AbsoluteTime, - AgeCommitment, - AgeRestriction, - AmountJson, - Amounts, - amountToPretty, - codecForExchangeMeltResponse, - codecForExchangeRevealResponse, - CoinPublicKeyString, - CoinRefreshRequest, - CoinStatus, - DenominationInfo, - DenomKeyType, - Duration, - durationFromSpec, - durationMul, - encodeCrock, - ExchangeMeltRequest, - ExchangeProtocolVersion, - ExchangeRefreshRevealRequest, - fnutil, - getErrorDetailFromException, - getRandomBytes, - HashCodeString, - HttpStatusCode, - j2s, - Logger, - makeErrorDetail, - NotificationType, - RefreshGroupId, - RefreshReason, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TransactionAction, - TransactionMajorState, - TransactionState, - TransactionType, - URL, -} from "@gnu-taler/taler-util"; -import { - readSuccessResponseJsonOrThrow, - readUnexpectedResponseDetails, -} from "@gnu-taler/taler-util/http"; -import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js"; -import { - DerivedRefreshSession, - RefreshNewDenomInfo, -} from "../crypto/cryptoTypes.js"; -import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js"; -import { - CoinRecord, - CoinSourceType, - DenominationRecord, - RefreshCoinStatus, - RefreshGroupRecord, - RefreshOperationStatus, - RefreshReasonDetails, - WalletStoresV1, -} from "../db.js"; -import { - getCandidateWithdrawalDenomsTx, - isWithdrawableDenom, - PendingTaskType, - RefreshSessionRecord, - timestampPreciseToDb, - timestampProtocolFromDb, -} from "../index.js"; -import { - EXCHANGE_COINS_LOCK, - InternalWalletState, -} from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { selectWithdrawalDenominations } from "../util/coinSelection.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; -import { - constructTaskIdentifier, - makeCoinAvailable, - makeCoinsVisible, - TaskRunResult, - TaskRunResultType, -} from "./common.js"; -import { fetchFreshExchange } from "./exchanges.js"; -import { - constructTransactionIdentifier, - notifyTransition, -} from "./transactions.js"; - -const logger = new Logger("refresh.ts"); - -/** - * Get the amount that we lose when refreshing a coin of the given denomination - * with a certain amount left. - * - * If the amount left is zero, then the refresh cost - * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of - * the right denominations), then the cost is the full amount left. - * - * Considers refresh fees, withdrawal fees after refresh and amounts too small - * to refresh. - */ -export function getTotalRefreshCost( - denoms: DenominationRecord[], - refreshedDenom: DenominationInfo, - amountLeft: AmountJson, - denomselAllowLate: boolean, -): AmountJson { - const withdrawAmount = Amounts.sub( - amountLeft, - refreshedDenom.feeRefresh, - ).amount; - const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x])); - const withdrawDenoms = selectWithdrawalDenominations( - withdrawAmount, - denoms, - denomselAllowLate, - ); - const resultingAmount = Amounts.add( - Amounts.zeroOfCurrency(withdrawAmount.currency), - ...withdrawDenoms.selectedDenoms.map( - (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount, - ), - ).amount; - const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; - logger.trace( - `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty( - totalCost, - )}`, - ); - return totalCost; -} - -function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } { - const allFinal = fnutil.all( - rg.statusPerCoin, - (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed, - ); - const anyFailed = fnutil.any( - rg.statusPerCoin, - (x) => x === RefreshCoinStatus.Failed, - ); - if (allFinal) { - if (anyFailed) { - rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now()); - rg.operationStatus = RefreshOperationStatus.Failed; - } else { - rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now()); - rg.operationStatus = RefreshOperationStatus.Finished; - } - return { final: true }; - } - return { final: false }; -} - -/** - * Create a refresh session for one particular coin inside a refresh group. - * - * If the session already exists, return the existing one. - * - * If the session doesn't need to be created (refresh group gone or session already - * finished), return undefined. - */ -async function provideRefreshSession( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<RefreshSessionRecord | undefined> { - logger.trace( - `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`, - ); - - const d = await ws.db - .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions]) - .runReadWrite(async (tx) => { - const refreshGroup = await tx.refreshGroups.get(refreshGroupId); - if (!refreshGroup) { - return; - } - if ( - refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished - ) { - return; - } - const existingRefreshSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex]; - const coin = await tx.coins.get(oldCoinPub); - if (!coin) { - throw Error("Can't refresh, coin not found"); - } - return { refreshGroup, coin, existingRefreshSession }; - }); - - if (!d) { - return undefined; - } - - if (d.existingRefreshSession) { - return d.existingRefreshSession; - } - - const { refreshGroup, coin } = d; - - const exch = await fetchFreshExchange(ws, coin.exchangeBaseUrl); - - // FIXME: use helper functions from withdraw.ts - // to update and filter withdrawable denoms. - - const { availableAmount, availableDenoms } = await ws.db - .mktx((x) => [x.denominations]) - .runReadOnly(async (tx) => { - const oldDenom = await ws.getDenomInfo( - ws, - tx, - exch.exchangeBaseUrl, - coin.denomPubHash, - ); - - if (!oldDenom) { - throw Error("db inconsistent: denomination for coin not found"); - } - - // FIXME: Use denom groups instead of querying all denominations! - const availableDenoms: DenominationRecord[] = - await tx.denominations.indexes.byExchangeBaseUrl - .iter(exch.exchangeBaseUrl) - .toArray(); - - const availableAmount = Amounts.sub( - refreshGroup.inputPerCoin[coinIndex], - oldDenom.feeRefresh, - ).amount; - return { availableAmount, availableDenoms }; - }); - - const newCoinDenoms = selectWithdrawalDenominations( - availableAmount, - availableDenoms, - ws.config.testing.denomselAllowLate, - ); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - - if (newCoinDenoms.selectedDenoms.length === 0) { - logger.trace( - `not refreshing, available amount ${amountToPretty( - availableAmount, - )} too small`, - ); - const transitionInfo = await ws.db - .mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups]) - .runReadWrite(async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - return; - } - const oldTxState = computeRefreshTransactionState(rg); - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; - const updateRes = updateGroupStatus(rg); - if (updateRes.final) { - await makeCoinsVisible(ws, tx, transactionId); - } - await tx.refreshGroups.put(rg); - const newTxState = computeRefreshTransactionState(rg); - return { oldTxState, newTxState }; - }); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - notifyTransition(ws, transactionId, transitionInfo); - return; - } - - const sessionSecretSeed = encodeCrock(getRandomBytes(64)); - - // Store refresh session for this coin in the database. - const mySession = await ws.db - .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions]) - .runReadWrite(async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - return; - } - const existingSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - if (existingSession) { - return existingSession; - } - const newSession: RefreshSessionRecord = { - coinIndex, - refreshGroupId, - norevealIndex: undefined, - sessionSecretSeed: sessionSecretSeed, - newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({ - count: x.count, - denomPubHash: x.denomPubHash, - })), - amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue), - }; - await tx.refreshSessions.put(newSession); - return newSession; - }); - logger.trace( - `found/created refresh session for coin #${coinIndex} in ${refreshGroupId}`, - ); - return mySession; -} - -function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration { - return Duration.fromSpec({ - seconds: 5, - }); -} - -async function refreshMelt( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<void> { - const d = await ws.db - .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations]) - .runReadWrite(async (tx) => { - const refreshGroup = await tx.refreshGroups.get(refreshGroupId); - if (!refreshGroup) { - return; - } - const refreshSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - if (!refreshSession) { - return; - } - if (refreshSession.norevealIndex !== undefined) { - return; - } - - const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]); - checkDbInvariant(!!oldCoin, "melt coin doesn't exist"); - const oldDenom = await ws.getDenomInfo( - ws, - tx, - oldCoin.exchangeBaseUrl, - oldCoin.denomPubHash, - ); - checkDbInvariant( - !!oldDenom, - "denomination for melted coin doesn't exist", - ); - - const newCoinDenoms: RefreshNewDenomInfo[] = []; - - for (const dh of refreshSession.newDenoms) { - const newDenom = await ws.getDenomInfo( - ws, - tx, - oldCoin.exchangeBaseUrl, - dh.denomPubHash, - ); - checkDbInvariant( - !!newDenom, - "new denomination for refresh not in database", - ); - newCoinDenoms.push({ - count: dh.count, - denomPub: newDenom.denomPub, - denomPubHash: newDenom.denomPubHash, - feeWithdraw: newDenom.feeWithdraw, - value: Amounts.stringify(newDenom.value), - }); - } - return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession }; - }); - - if (!d) { - return; - } - - const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d; - - let exchangeProtocolVersion: ExchangeProtocolVersion; - switch (d.oldDenom.denomPub.cipher) { - case DenomKeyType.Rsa: { - exchangeProtocolVersion = ExchangeProtocolVersion.V12; - break; - } - default: - throw Error("unsupported key type"); - } - - const derived = await ws.cryptoApi.deriveRefreshSession({ - exchangeProtocolVersion, - kappa: 3, - meltCoinDenomPubHash: oldCoin.denomPubHash, - meltCoinPriv: oldCoin.coinPriv, - meltCoinPub: oldCoin.coinPub, - feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), - meltCoinMaxAge: oldCoin.maxAge, - meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, - newCoinDenoms, - sessionSecretSeed: refreshSession.sessionSecretSeed, - }); - - const reqUrl = new URL( - `coins/${oldCoin.coinPub}/melt`, - oldCoin.exchangeBaseUrl, - ); - - let maybeAch: HashCodeString | undefined; - if (oldCoin.ageCommitmentProof) { - maybeAch = AgeRestriction.hashCommitment( - oldCoin.ageCommitmentProof.commitment, - ); - } - - const meltReqBody: ExchangeMeltRequest = { - coin_pub: oldCoin.coinPub, - confirm_sig: derived.confirmSig, - denom_pub_hash: oldCoin.denomPubHash, - denom_sig: oldCoin.denomSig, - rc: derived.hash, - value_with_fee: Amounts.stringify(derived.meltValueWithFee), - age_commitment_hash: maybeAch, - }; - - const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { - return await ws.http.postJson(reqUrl.href, meltReqBody, { - timeout: getRefreshRequestTimeout(refreshGroup), - }); - }); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - - if (resp.status === HttpStatusCode.NotFound) { - const errDetails = await readUnexpectedResponseDetails(resp); - const transitionInfo = await ws.db - .mktx((x) => [ - x.refreshGroups, - x.refreshSessions, - x.coins, - x.coinAvailability, - ]) - .runReadWrite(async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - return; - } - if (rg.timestampFinished) { - return; - } - if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { - return; - } - const oldTxState = computeRefreshTransactionState(rg); - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; - const refreshSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - if (!refreshSession) { - throw Error( - "db invariant failed: missing refresh session in database", - ); - } - refreshSession.lastError = errDetails; - const updateRes = updateGroupStatus(rg); - if (updateRes.final) { - await makeCoinsVisible(ws, tx, transactionId); - } - await tx.refreshGroups.put(rg); - await tx.refreshSessions.put(refreshSession); - const newTxState = computeRefreshTransactionState(rg); - return { - oldTxState, - newTxState, - }; - }); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - notifyTransition(ws, transactionId, transitionInfo); - return; - } - - if (resp.status === HttpStatusCode.Conflict) { - // Just log for better diagnostics here, error status - // will be handled later. - logger.error( - `melt request for ${Amounts.stringify( - derived.meltValueWithFee, - )} failed in refresh group ${refreshGroupId} due to conflict`, - ); - - const historySig = await ws.cryptoApi.signCoinHistoryRequest({ - coinPriv: oldCoin.coinPriv, - coinPub: oldCoin.coinPub, - startOffset: 0, - }); - - const historyUrl = new URL( - `coins/${oldCoin.coinPub}/history`, - oldCoin.exchangeBaseUrl, - ); - - const historyResp = await ws.http.fetch(historyUrl.href, { - method: "GET", - headers: { - "Taler-Coin-History-Signature": historySig.sig, - }, - }); - - const historyJson = await historyResp.json(); - logger.info(`coin history: ${j2s(historyJson)}`); - - // FIXME: Before failing and re-trying, analyse response and adjust amount - } - - const meltResponse = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeMeltResponse(), - ); - - const norevealIndex = meltResponse.noreveal_index; - - refreshSession.norevealIndex = norevealIndex; - - await ws.db - .mktx((x) => [x.refreshGroups, x.refreshSessions]) - .runReadWrite(async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - return; - } - if (rg.timestampFinished) { - return; - } - const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); - if (!rs) { - return; - } - if (rs.norevealIndex !== undefined) { - return; - } - rs.norevealIndex = norevealIndex; - await tx.refreshSessions.put(rs); - }); -} - -export async function assembleRefreshRevealRequest(args: { - cryptoApi: TalerCryptoInterface; - derived: DerivedRefreshSession; - norevealIndex: number; - oldCoinPub: CoinPublicKeyString; - oldCoinPriv: string; - newDenoms: { - denomPubHash: string; - count: number; - }[]; - oldAgeCommitment?: AgeCommitment; -}): Promise<ExchangeRefreshRevealRequest> { - const { - derived, - norevealIndex, - cryptoApi, - oldCoinPriv, - oldCoinPub, - newDenoms, - } = args; - const privs = Array.from(derived.transferPrivs); - privs.splice(norevealIndex, 1); - - const planchets = derived.planchetsForGammas[norevealIndex]; - if (!planchets) { - throw Error("refresh index error"); - } - - const newDenomsFlat: string[] = []; - const linkSigs: string[] = []; - - for (let i = 0; i < newDenoms.length; i++) { - const dsel = newDenoms[i]; - for (let j = 0; j < dsel.count; j++) { - const newCoinIndex = linkSigs.length; - const linkSig = await cryptoApi.signCoinLink({ - coinEv: planchets[newCoinIndex].coinEv, - newDenomHash: dsel.denomPubHash, - oldCoinPriv: oldCoinPriv, - oldCoinPub: oldCoinPub, - transferPub: derived.transferPubs[norevealIndex], - }); - linkSigs.push(linkSig.sig); - newDenomsFlat.push(dsel.denomPubHash); - } - } - - const req: ExchangeRefreshRevealRequest = { - coin_evs: planchets.map((x) => x.coinEv), - new_denoms_h: newDenomsFlat, - transfer_privs: privs, - transfer_pub: derived.transferPubs[norevealIndex], - link_sigs: linkSigs, - old_age_commitment: args.oldAgeCommitment?.publicKeys, - }; - return req; -} - -async function refreshReveal( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<void> { - logger.trace( - `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`, - ); - const d = await ws.db - .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations]) - .runReadOnly(async (tx) => { - const refreshGroup = await tx.refreshGroups.get(refreshGroupId); - if (!refreshGroup) { - return; - } - const refreshSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - if (!refreshSession) { - return; - } - const norevealIndex = refreshSession.norevealIndex; - if (norevealIndex === undefined) { - throw Error("can't reveal without melting first"); - } - - const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]); - checkDbInvariant(!!oldCoin, "melt coin doesn't exist"); - const oldDenom = await ws.getDenomInfo( - ws, - tx, - oldCoin.exchangeBaseUrl, - oldCoin.denomPubHash, - ); - checkDbInvariant( - !!oldDenom, - "denomination for melted coin doesn't exist", - ); - - const newCoinDenoms: RefreshNewDenomInfo[] = []; - - for (const dh of refreshSession.newDenoms) { - const newDenom = await ws.getDenomInfo( - ws, - tx, - oldCoin.exchangeBaseUrl, - dh.denomPubHash, - ); - checkDbInvariant( - !!newDenom, - "new denomination for refresh not in database", - ); - newCoinDenoms.push({ - count: dh.count, - denomPub: newDenom.denomPub, - denomPubHash: newDenom.denomPubHash, - feeWithdraw: newDenom.feeWithdraw, - value: Amounts.stringify(newDenom.value), - }); - } - return { - oldCoin, - oldDenom, - newCoinDenoms, - refreshSession, - refreshGroup, - norevealIndex, - }; - }); - - if (!d) { - return; - } - - const { - oldCoin, - oldDenom, - newCoinDenoms, - refreshSession, - refreshGroup, - norevealIndex, - } = d; - - let exchangeProtocolVersion: ExchangeProtocolVersion; - switch (d.oldDenom.denomPub.cipher) { - case DenomKeyType.Rsa: { - exchangeProtocolVersion = ExchangeProtocolVersion.V12; - break; - } - default: - throw Error("unsupported key type"); - } - - const derived = await ws.cryptoApi.deriveRefreshSession({ - exchangeProtocolVersion, - kappa: 3, - meltCoinDenomPubHash: oldCoin.denomPubHash, - meltCoinPriv: oldCoin.coinPriv, - meltCoinPub: oldCoin.coinPub, - feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), - newCoinDenoms, - meltCoinMaxAge: oldCoin.maxAge, - meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, - sessionSecretSeed: refreshSession.sessionSecretSeed, - }); - - const reqUrl = new URL( - `refreshes/${derived.hash}/reveal`, - oldCoin.exchangeBaseUrl, - ); - - const req = await assembleRefreshRevealRequest({ - cryptoApi: ws.cryptoApi, - derived, - newDenoms: newCoinDenoms, - norevealIndex: norevealIndex, - oldCoinPriv: oldCoin.coinPriv, - oldCoinPub: oldCoin.coinPub, - oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment, - }); - - const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { - return await ws.http.postJson(reqUrl.href, req, { - timeout: getRefreshRequestTimeout(refreshGroup), - }); - }); - - const reveal = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeRevealResponse(), - ); - - const coins: CoinRecord[] = []; - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - - for (let i = 0; i < refreshSession.newDenoms.length; i++) { - const ncd = newCoinDenoms[i]; - for (let j = 0; j < refreshSession.newDenoms[i].count; j++) { - const newCoinIndex = coins.length; - const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex]; - if (ncd.denomPub.cipher !== DenomKeyType.Rsa) { - throw Error("cipher unsupported"); - } - const evSig = reveal.ev_sigs[newCoinIndex].ev_sig; - const denomSig = await ws.cryptoApi.unblindDenominationSignature({ - planchet: { - blindingKey: pc.blindingKey, - denomPub: ncd.denomPub, - }, - evSig, - }); - const coin: CoinRecord = { - blindingKey: pc.blindingKey, - coinPriv: pc.coinPriv, - coinPub: pc.coinPub, - denomPubHash: ncd.denomPubHash, - denomSig, - exchangeBaseUrl: oldCoin.exchangeBaseUrl, - status: CoinStatus.Fresh, - coinSource: { - type: CoinSourceType.Refresh, - refreshGroupId, - oldCoinPub: refreshGroup.oldCoinPubs[coinIndex], - }, - sourceTransactionId: transactionId, - coinEvHash: pc.coinEvHash, - maxAge: pc.maxAge, - ageCommitmentProof: pc.ageCommitmentProof, - spendAllocation: undefined, - }; - - coins.push(coin); - } - } - - const transitionInfo = await ws.db - .mktx((x) => [ - x.coins, - x.denominations, - x.coinAvailability, - x.refreshGroups, - x.refreshSessions, - ]) - .runReadWrite(async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - logger.warn("no refresh session found"); - return; - } - const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); - if (!rs) { - return; - } - const oldTxState = computeRefreshTransactionState(rg); - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; - updateGroupStatus(rg); - for (const coin of coins) { - await makeCoinAvailable(ws, tx, coin); - } - await makeCoinsVisible(ws, tx, transactionId); - await tx.refreshGroups.put(rg); - const newTxState = computeRefreshTransactionState(rg); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - logger.trace("refresh finished (end of reveal)"); -} - -export async function processRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, - options: Record<string, never> = {}, -): Promise<TaskRunResult> { - logger.trace(`processing refresh group ${refreshGroupId}`); - - const refreshGroup = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadOnly(async (tx) => tx.refreshGroups.get(refreshGroupId)); - if (!refreshGroup) { - return TaskRunResult.finished(); - } - if (refreshGroup.timestampFinished) { - return TaskRunResult.finished(); - } - // Process refresh sessions of the group in parallel. - logger.trace("processing refresh sessions for $ old coins"); - let errors: TalerErrorDetail[] = []; - let inShutdown = false; - const ps = refreshGroup.oldCoinPubs.map((x, i) => - processRefreshSession(ws, refreshGroupId, i).catch((x) => { - if (x instanceof CryptoApiStoppedError) { - inShutdown = true; - logger.info( - "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.", - ); - return; - } - if (x instanceof TalerError) { - logger.warn("process refresh session got exception (TalerError)"); - logger.warn(`exc ${x}`); - logger.warn(`exc stack ${x.stack}`); - logger.warn(`error detail: ${j2s(x.errorDetail)}`); - } else { - logger.warn("process refresh session got exception"); - logger.warn(`exc ${x}`); - logger.warn(`exc stack ${x.stack}`); - } - errors.push(getErrorDetailFromException(x)); - }), - ); - try { - logger.info("waiting for refreshes"); - await Promise.all(ps); - logger.info("refresh group finished"); - } catch (e) { - logger.warn("process refresh sessions got exception"); - logger.warn(`exception: ${e}`); - } - if (inShutdown) { - return TaskRunResult.pending(); - } - if (errors.length > 0) { - return { - type: TaskRunResultType.Error, - errorDetail: makeErrorDetail( - TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE, - { - numErrors: errors.length, - errors: errors.slice(0, 5), - }, - ), - }; - } - - return TaskRunResult.pending(); -} - -async function processRefreshSession( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<void> { - logger.trace( - `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`, - ); - let { refreshGroup, refreshSession } = await ws.db - .mktx((x) => [x.refreshGroups, x.refreshSessions]) - .runReadOnly(async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); - return { - refreshGroup: rg, - refreshSession: rs, - }; - }); - if (!refreshGroup) { - return; - } - if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) { - return; - } - if (!refreshSession) { - refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex); - } - if (!refreshSession) { - // We tried to create the refresh session, but didn't get a result back. - // This means that either the session is finished, or that creating - // one isn't necessary. - return; - } - if (refreshSession.norevealIndex === undefined) { - await refreshMelt(ws, refreshGroupId, coinIndex); - } - await refreshReveal(ws, refreshGroupId, coinIndex); -} - -export interface RefreshOutputInfo { - outputPerCoin: AmountJson[]; -} - -export async function calculateRefreshOutput( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, - currency: string, - oldCoinPubs: CoinRefreshRequest[], -): Promise<RefreshOutputInfo> { - const estimatedOutputPerCoin: AmountJson[] = []; - - const denomsPerExchange: Record<string, DenominationRecord[]> = {}; - - // FIXME: Use denom groups instead of querying all denominations! - const getDenoms = async ( - exchangeBaseUrl: string, - ): Promise<DenominationRecord[]> => { - if (denomsPerExchange[exchangeBaseUrl]) { - return denomsPerExchange[exchangeBaseUrl]; - } - const allDenoms = await getCandidateWithdrawalDenomsTx( - ws, - tx, - exchangeBaseUrl, - currency, - ); - denomsPerExchange[exchangeBaseUrl] = allDenoms; - return allDenoms; - }; - - for (const ocp of oldCoinPubs) { - const coin = await tx.coins.get(ocp.coinPub); - checkDbInvariant(!!coin, "coin must be in database"); - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant( - !!denom, - "denomination for existing coin must be in database", - ); - const refreshAmount = ocp.amount; - const denoms = await getDenoms(coin.exchangeBaseUrl); - const cost = getTotalRefreshCost( - denoms, - denom, - Amounts.parseOrThrow(refreshAmount), - ws.config.testing.denomselAllowLate, - ); - const output = Amounts.sub(refreshAmount, cost).amount; - estimatedOutputPerCoin.push(output); - } - - return { - outputPerCoin: estimatedOutputPerCoin, - }; -} - -async function applyRefresh( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, - oldCoinPubs: CoinRefreshRequest[], - refreshGroupId: string, -): Promise<void> { - for (const ocp of oldCoinPubs) { - const coin = await tx.coins.get(ocp.coinPub); - checkDbInvariant(!!coin, "coin must be in database"); - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant( - !!denom, - "denomination for existing coin must be in database", - ); - switch (coin.status) { - case CoinStatus.Dormant: - break; - case CoinStatus.Fresh: { - coin.status = CoinStatus.Dormant; - const coinAv = await tx.coinAvailability.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - coin.maxAge, - ]); - checkDbInvariant(!!coinAv); - checkDbInvariant(coinAv.freshCoinCount > 0); - coinAv.freshCoinCount--; - await tx.coinAvailability.put(coinAv); - break; - } - case CoinStatus.FreshSuspended: { - // For suspended coins, we don't have to adjust coin - // availability, as they are not counted as available. - coin.status = CoinStatus.Dormant; - break; - } - default: - assertUnreachable(coin.status); - } - if (!coin.spendAllocation) { - coin.spendAllocation = { - amount: Amounts.stringify(ocp.amount), - // id: `txn:refresh:${refreshGroupId}`, - id: constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }), - }; - } - await tx.coins.put(coin); - } -} - -/** - * Create a refresh group for a list of coins. - * - * Refreshes the remaining amount on the coin, effectively capturing the remaining - * value in the refresh group. - * - * The caller must also ensure that the coins that should be refreshed exist - * in the current database transaction. - */ -export async function createRefreshGroup( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, - currency: string, - oldCoinPubs: CoinRefreshRequest[], - reason: RefreshReason, - reasonDetails?: RefreshReasonDetails, -): Promise<RefreshGroupId> { - const refreshGroupId = encodeCrock(getRandomBytes(32)); - - const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs); - - const estimatedOutputPerCoin = outInfo.outputPerCoin; - - await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId); - - const refreshGroup: RefreshGroupRecord = { - operationStatus: RefreshOperationStatus.Pending, - currency, - timestampFinished: undefined, - statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending), - oldCoinPubs: oldCoinPubs.map((x) => x.coinPub), - reasonDetails, - reason, - refreshGroupId, - inputPerCoin: oldCoinPubs.map((x) => x.amount), - expectedOutputPerCoin: estimatedOutputPerCoin.map((x) => - Amounts.stringify(x), - ), - timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }; - - if (oldCoinPubs.length == 0) { - logger.warn("created refresh group with zero coins"); - refreshGroup.timestampFinished = timestampPreciseToDb( - TalerPreciseTimestamp.now(), - ); - refreshGroup.operationStatus = RefreshOperationStatus.Finished; - } - - await tx.refreshGroups.put(refreshGroup); - - logger.trace(`created refresh group ${refreshGroupId}`); - - return { - refreshGroupId, - }; -} - -/** - * Timestamp after which the wallet would do the next check for an auto-refresh. - */ -function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime { - const expireWithdraw = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampExpireWithdraw), - ); - const expireDeposit = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampExpireDeposit), - ); - const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit); - const deltaDiv = durationMul(delta, 0.75); - return AbsoluteTime.addDuration(expireWithdraw, deltaDiv); -} - -/** - * Timestamp after which the wallet would do an auto-refresh. - */ -export function getAutoRefreshExecuteThreshold(d: { - stampExpireWithdraw: TalerProtocolTimestamp; - stampExpireDeposit: TalerProtocolTimestamp; -}): AbsoluteTime { - const expireWithdraw = AbsoluteTime.fromProtocolTimestamp( - d.stampExpireWithdraw, - ); - const expireDeposit = AbsoluteTime.fromProtocolTimestamp( - d.stampExpireDeposit, - ); - const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit); - const deltaDiv = durationMul(delta, 0.5); - return AbsoluteTime.addDuration(expireWithdraw, deltaDiv); -} - -function getAutoRefreshExecuteThresholdForDenom( - d: DenominationRecord, -): AbsoluteTime { - return getAutoRefreshExecuteThreshold({ - stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), - stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), - }); -} - -export async function autoRefresh( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<TaskRunResult> { - logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); - - // We must make sure that the exchange is up-to-date so that - // can refresh into new denominations. - await fetchFreshExchange(ws, exchangeBaseUrl); - - let minCheckThreshold = AbsoluteTime.addDuration( - AbsoluteTime.now(), - durationFromSpec({ days: 1 }), - ); - await ws.db - .mktx((x) => [ - x.coins, - x.denominations, - x.coinAvailability, - x.refreshGroups, - x.exchanges, - ]) - .runReadWrite(async (tx) => { - const exchange = await tx.exchanges.get(exchangeBaseUrl); - if (!exchange || !exchange.detailsPointer) { - return; - } - const coins = await tx.coins.indexes.byBaseUrl - .iter(exchangeBaseUrl) - .toArray(); - const refreshCoins: CoinRefreshRequest[] = []; - for (const coin of coins) { - if (coin.status !== CoinStatus.Fresh) { - continue; - } - const denom = await tx.denominations.get([ - exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - logger.warn("denomination not in database"); - continue; - } - const executeThreshold = getAutoRefreshExecuteThresholdForDenom(denom); - if (AbsoluteTime.isExpired(executeThreshold)) { - refreshCoins.push({ - coinPub: coin.coinPub, - amount: denom.value, - }); - } else { - const checkThreshold = getAutoRefreshCheckThreshold(denom); - minCheckThreshold = AbsoluteTime.min( - minCheckThreshold, - checkThreshold, - ); - } - } - if (refreshCoins.length > 0) { - const res = await createRefreshGroup( - ws, - tx, - exchange.detailsPointer?.currency, - refreshCoins, - RefreshReason.Scheduled, - ); - logger.trace( - `created refresh group for auto-refresh (${res.refreshGroupId})`, - ); - } - // logger.trace( - // `current wallet time: ${AbsoluteTime.toIsoString(AbsoluteTime.now())}`, - // ); - logger.trace( - `next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`, - ); - exchange.nextRefreshCheckStamp = timestampPreciseToDb( - AbsoluteTime.toPreciseTimestamp(minCheckThreshold), - ); - await tx.exchanges.put(exchange); - }); - return TaskRunResult.finished(); -} - -export function computeRefreshTransactionState( - rg: RefreshGroupRecord, -): TransactionState { - switch (rg.operationStatus) { - case RefreshOperationStatus.Finished: - return { - major: TransactionMajorState.Done, - }; - case RefreshOperationStatus.Failed: - return { - major: TransactionMajorState.Failed, - }; - case RefreshOperationStatus.Pending: - return { - major: TransactionMajorState.Pending, - }; - case RefreshOperationStatus.Suspended: - return { - major: TransactionMajorState.Suspended, - }; - } -} - -export function computeRefreshTransactionActions( - rg: RefreshGroupRecord, -): TransactionAction[] { - switch (rg.operationStatus) { - case RefreshOperationStatus.Finished: - return [TransactionAction.Delete]; - case RefreshOperationStatus.Failed: - return [TransactionAction.Delete]; - case RefreshOperationStatus.Pending: - return [TransactionAction.Suspend, TransactionAction.Fail]; - case RefreshOperationStatus.Suspended: - return [TransactionAction.Resume, TransactionAction.Fail]; - } -} - -export async function suspendRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - let res = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadWrite(async (tx) => { - const dg = await tx.refreshGroups.get(refreshGroupId); - if (!dg) { - logger.warn( - `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`, - ); - return undefined; - } - const oldState = computeRefreshTransactionState(dg); - switch (dg.operationStatus) { - case RefreshOperationStatus.Finished: - return undefined; - case RefreshOperationStatus.Pending: { - dg.operationStatus = RefreshOperationStatus.Suspended; - await tx.refreshGroups.put(dg); - return { - oldTxState: oldState, - newTxState: computeRefreshTransactionState(dg), - }; - } - case RefreshOperationStatus.Suspended: - return undefined; - } - return undefined; - }); - if (res) { - ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId, - oldTxState: res.oldTxState, - newTxState: res.newTxState, - }); - } -} - -export async function resumeRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadWrite(async (tx) => { - const dg = await tx.refreshGroups.get(refreshGroupId); - if (!dg) { - logger.warn( - `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`, - ); - return; - } - const oldState = computeRefreshTransactionState(dg); - switch (dg.operationStatus) { - case RefreshOperationStatus.Finished: - return; - case RefreshOperationStatus.Pending: { - return; - } - case RefreshOperationStatus.Suspended: - dg.operationStatus = RefreshOperationStatus.Pending; - await tx.refreshGroups.put(dg); - return { - oldTxState: oldState, - newTxState: computeRefreshTransactionState(dg), - }; - } - return undefined; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function failRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - throw Error("action cancel-aborting not allowed on refreshes"); -} - -export async function abortRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadWrite(async (tx) => { - const dg = await tx.refreshGroups.get(refreshGroupId); - if (!dg) { - logger.warn( - `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`, - ); - return; - } - const oldState = computeRefreshTransactionState(dg); - let newStatus: RefreshOperationStatus | undefined; - switch (dg.operationStatus) { - case RefreshOperationStatus.Finished: - break; - case RefreshOperationStatus.Pending: - case RefreshOperationStatus.Suspended: - newStatus = RefreshOperationStatus.Failed; - break; - case RefreshOperationStatus.Failed: - break; - default: - assertUnreachable(dg.operationStatus); - } - if (newStatus) { - dg.operationStatus = newStatus; - await tx.refreshGroups.put(dg); - } - return { - oldTxState: oldState, - newTxState: computeRefreshTransactionState(dg), - }; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} |