summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/refresh.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/refresh.ts')
-rw-r--r--packages/taler-wallet-core/src/refresh.ts1430
1 files changed, 1430 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
new file mode 100644
index 000000000..ea4190364
--- /dev/null
+++ b/packages/taler-wallet-core/src/refresh.ts
@@ -0,0 +1,1430 @@
+/*
+ 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 {
+ AgeCommitment,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ amountToPretty,
+ CancellationToken,
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+ CoinPublicKeyString,
+ CoinRefreshRequest,
+ CoinStatus,
+ DenominationInfo,
+ DenomKeyType,
+ Duration,
+ encodeCrock,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ ExchangeRefreshRevealRequest,
+ fnutil,
+ ForceRefreshRequest,
+ getErrorDetailFromException,
+ getRandomBytes,
+ HashCodeString,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ NotificationType,
+ RefreshGroupId,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ 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,
+} from "./db.js";
+import {
+ getCandidateWithdrawalDenomsTx,
+ PendingTaskType,
+ RefreshGroupPerExchangeInfo,
+ RefreshSessionRecord,
+ TaskId,
+ timestampPreciseToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+} 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 {
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+} from "./common.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+
+const logger = new Logger("refresh.ts");
+
+export class RefreshTransactionContext implements TransactionContext {
+ public transactionId: string;
+ readonly taskId: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public refreshGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const refreshGroupId = this.refreshGroupId;
+ const ws = this.ws;
+ await ws.db.runReadWriteTx(["refreshGroups", "tombstones"], async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (rg) {
+ await tx.refreshGroups.delete(refreshGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
+ });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, refreshGroupId, transactionId } = this;
+ let res = await ws.db.runReadWriteTx(["refreshGroups"], 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,
+ });
+ }
+ }
+
+ async abortTransaction(): Promise<void> {
+ // Refresh transactions only support fail, not abort.
+ throw new Error("refresh transactions cannot be aborted");
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, refreshGroupId, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["refreshGroups"],
+ 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;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, refreshGroupId, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["refreshGroups"],
+ 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.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+}
+
+/**
+ * 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.runReadWriteTx(
+ ["coins", "refreshGroups", "refreshSessions"],
+ 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.runReadOnlyTx(
+ ["denominations"],
+ 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.runReadWriteTx(
+ ["refreshGroups", "coins", "coinAvailability"],
+ 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.runReadWriteTx(
+ ["refreshGroups", "refreshSessions"],
+ 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.runReadWriteTx(
+ ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ 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.fetch(reqUrl.href, {
+ method: "POST",
+ body: 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.runReadWriteTx(
+ ["refreshGroups", "refreshSessions", "coins", "coinAvailability"],
+ 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.runReadWriteTx(
+ ["refreshGroups", "refreshSessions"],
+ 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.runReadOnlyTx(
+ ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ 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.fetch(reqUrl.href, {
+ body: req,
+ method: "POST",
+ 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.runReadWriteTx(
+ [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ 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,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.trace(`processing refresh group ${refreshGroupId}`);
+
+ const refreshGroup = await ws.db.runReadOnlyTx(
+ ["refreshGroups"],
+ 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 ${refreshGroup.oldCoinPubs.length} 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.backoff();
+ }
+ 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.backoff();
+}
+
+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.runReadOnlyTx(
+ ["refreshGroups", "refreshSessions"],
+ 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[];
+ perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>;
+}
+
+export async function calculateRefreshOutput(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+): Promise<RefreshOutputInfo> {
+ const estimatedOutputPerCoin: AmountJson[] = [];
+
+ const denomsPerExchange: Record<string, DenominationRecord[]> = {};
+
+ const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {};
+
+ // 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;
+ let exchInfo = infoPerExchange[coin.exchangeBaseUrl];
+ if (!exchInfo) {
+ infoPerExchange[coin.exchangeBaseUrl] = exchInfo = {
+ outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)),
+ };
+ }
+ exchInfo.outputEffective = Amounts.stringify(
+ Amounts.add(exchInfo.outputEffective, output).amount,
+ );
+ estimatedOutputPerCoin.push(output);
+ }
+
+ return {
+ outputPerCoin: estimatedOutputPerCoin,
+ perExchangeInfo: infoPerExchange,
+ };
+}
+
+async function applyRefresh(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "refreshGroups", "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);
+ }
+}
+
+export interface CreateRefreshGroupResult {
+ refreshGroupId: string;
+}
+
+/**
+ * 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: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshReason: RefreshReason,
+ originatingTransactionId: string | undefined,
+): Promise<CreateRefreshGroupResult> {
+ 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),
+ originatingTransactionId,
+ reason: refreshReason,
+ 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}`);
+
+ const ctx = new RefreshTransactionContext(ws, refreshGroupId);
+
+ // Shepherd the task.
+ // If the current transaction fails to commit the refresh
+ // group to the DB, the shepherd will give up.
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ refreshGroupId,
+ };
+}
+
+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.Retry,
+ TransactionAction.Suspend,
+ TransactionAction.Fail,
+ ];
+ case RefreshOperationStatus.Suspended:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
+
+export function getRefreshesForTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<string[]> {
+ return ws.db.runReadOnlyTx(["refreshGroups"], async (tx) => {
+ const groups =
+ await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
+ transactionId,
+ );
+ return groups.map((x) =>
+ constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: x.refreshGroupId,
+ }),
+ );
+ });
+}
+
+export async function forceRefresh(
+ ws: InternalWalletState,
+ req: ForceRefreshRequest,
+): Promise<{ refreshGroupId: RefreshGroupId }> {
+ if (req.coinPubList.length == 0) {
+ throw Error("refusing to create empty refresh group");
+ }
+ const refreshGroupId = await ws.db.runReadWriteTx(
+ ["refreshGroups", "coinAvailability", "denominations", "coins"],
+ async (tx) => {
+ let coinPubs: CoinRefreshRequest[] = [];
+ for (const c of req.coinPubList) {
+ const coin = await tx.coins.get(c);
+ if (!coin) {
+ throw Error(`coin (pubkey ${c}) not found`);
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ coinPubs.push({
+ coinPub: c,
+ amount: denom?.value,
+ });
+ }
+ return await createRefreshGroup(
+ ws,
+ tx,
+ Amounts.currencyOf(coinPubs[0].amount),
+ coinPubs,
+ RefreshReason.Manual,
+ undefined,
+ );
+ },
+ );
+
+ return {
+ refreshGroupId,
+ };
+}