wallet-core/packages/taler-wallet-core/src/operations/refresh.ts

1091 lines
30 KiB
TypeScript
Raw Normal View History

/*
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/>
*/
2021-11-17 10:23:22 +01:00
import {
2022-03-15 17:51:05 +01:00
CoinPublicKeyString,
2021-11-17 10:23:22 +01:00
DenomKeyType,
encodeCrock,
ExchangeMeltRequest,
ExchangeProtocolVersion,
ExchangeRefreshRevealRequest,
2021-11-17 10:23:22 +01:00
getRandomBytes,
HttpStatusCode,
2022-03-18 15:32:41 +01:00
TalerProtocolTimestamp,
2021-11-17 10:23:22 +01:00
} from "@gnu-taler/taler-util";
import {
CoinRecord,
CoinSourceType,
2021-02-08 15:38:34 +01:00
CoinStatus,
DenominationRecord,
2022-01-11 21:00:12 +01:00
OperationStatus,
2021-08-24 14:25:46 +02:00
RefreshCoinStatus,
2021-02-08 15:38:34 +01:00
RefreshGroupRecord,
2021-06-09 15:14:17 +02:00
WalletStoresV1,
2021-03-17 17:56:37 +01:00
} from "../db.js";
2021-02-08 15:38:34 +01:00
import {
codecForExchangeMeltResponse,
codecForExchangeRevealResponse,
CoinPublicKey,
2021-08-24 14:25:46 +02:00
fnutil,
2021-03-17 17:56:37 +01:00
NotificationType,
RefreshGroupId,
2021-02-08 15:38:34 +01:00
RefreshReason,
TalerErrorDetail,
2021-03-17 17:56:37 +01:00
} from "@gnu-taler/taler-util";
import { AmountJson, Amounts } from "@gnu-taler/taler-util";
import { amountToPretty } from "@gnu-taler/taler-util";
2021-08-24 14:25:46 +02:00
import {
readSuccessResponseJsonOrThrow,
readUnexpectedResponseDetails,
} from "../util/http.js";
2021-06-14 16:08:58 +02:00
import { checkDbInvariant } from "../util/invariants.js";
2021-06-08 20:58:13 +02:00
import { Logger } from "@gnu-taler/taler-util";
import {
resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
2020-09-03 17:08:26 +02:00
import {
Duration,
durationFromSpec,
2021-02-08 15:38:34 +01:00
durationMul,
2022-03-18 15:32:41 +01:00
AbsoluteTime,
URL,
2021-03-17 17:56:37 +01:00
} from "@gnu-taler/taler-util";
2021-06-14 16:08:58 +02:00
import { updateExchangeFromUrl } from "./exchanges.js";
2022-01-13 22:01:14 +01:00
import {
DenomInfo,
EXCHANGE_COINS_LOCK,
InternalWalletState,
} from "../internal-wallet-state.js";
import {
isWithdrawableDenom,
selectWithdrawalDenominations,
} from "./withdraw.js";
2022-03-15 17:51:05 +01:00
import {
DerivedRefreshSession,
RefreshNewDenomInfo,
} from "../crypto/cryptoTypes.js";
2021-06-09 15:14:17 +02:00
import { GetReadWriteAccess } from "../util/query.js";
import { guardOperationException } from "./common.js";
2022-03-23 21:24:23 +01:00
import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js";
import { TalerCryptoInterface } from "../crypto/cryptoImplementation.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[],
2022-01-13 22:01:14 +01:00
refreshedDenom: DenomInfo,
amountLeft: AmountJson,
): AmountJson {
const withdrawAmount = Amounts.sub(
amountLeft,
refreshedDenom.feeRefresh,
).amount;
const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
const withdrawDenoms = selectWithdrawalDenominations(withdrawAmount, denoms);
const resultingAmount = Amounts.add(
Amounts.getZero(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(
2020-05-15 19:24:39 +02:00
`total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
totalCost,
)}`,
);
return totalCost;
}
2021-08-24 14:25:46 +02:00
function updateGroupStatus(rg: RefreshGroupRecord): void {
let allDone = fnutil.all(
rg.statusPerCoin,
(x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Frozen,
);
let anyFrozen = fnutil.any(
rg.statusPerCoin,
(x) => x === RefreshCoinStatus.Frozen,
);
if (allDone) {
if (anyFrozen) {
rg.frozen = true;
rg.retryInfo = resetRetryInfo();
2021-08-24 14:25:46 +02:00
} else {
2022-03-18 15:32:41 +01:00
rg.timestampFinished = AbsoluteTime.toTimestamp(AbsoluteTime.now());
2022-01-11 21:00:12 +01:00
rg.operationStatus = OperationStatus.Finished;
rg.retryInfo = resetRetryInfo();
2021-08-24 14:25:46 +02:00
}
}
}
/**
2021-06-09 15:14:17 +02:00
* Create a refresh session for one particular coin inside a refresh group.
*/
async function refreshCreateSession(
ws: InternalWalletState,
refreshGroupId: string,
coinIndex: number,
): Promise<void> {
logger.trace(
`creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
);
2021-06-09 15:14:17 +02:00
const d = await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
coins: x.coins,
}))
.runReadWrite(async (tx) => {
const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
if (!refreshGroup) {
return;
}
2021-08-24 14:25:46 +02:00
if (
refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
) {
2021-06-09 15:14:17 +02:00
return;
}
const existingRefreshSession =
refreshGroup.refreshSessionPerCoin[coinIndex];
if (existingRefreshSession) {
return;
}
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 };
});
if (!d) {
return;
}
2021-06-09 15:14:17 +02:00
const { refreshGroup, coin } = d;
const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
if (!exchange) {
throw Error("db inconsistent: exchange of coin not found");
}
2021-08-19 16:06:09 +02:00
// FIXME: use helper functions from withdraw.ts
// to update and filter withdrawable denoms.
2021-06-09 15:14:17 +02:00
const { availableAmount, availableDenoms } = await ws.db
.mktx((x) => ({
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
2022-01-13 22:01:14 +01:00
const oldDenom = await ws.getDenomInfo(
ws,
tx,
2021-06-09 15:14:17 +02:00
exchange.baseUrl,
coin.denomPubHash,
2022-01-13 22:01:14 +01:00
);
2021-06-09 15:14:17 +02:00
if (!oldDenom) {
throw Error("db inconsistent: denomination for coin not found");
}
// FIXME: use an index here, based on the withdrawal expiration time.
const availableDenoms: DenominationRecord[] =
await tx.denominations.indexes.byExchangeBaseUrl
.iter(exchange.baseUrl)
.toArray();
2021-06-09 15:14:17 +02:00
const availableAmount = Amounts.sub(
refreshGroup.inputPerCoin[coinIndex],
oldDenom.feeRefresh,
).amount;
return { availableAmount, availableDenoms };
});
2021-01-13 00:51:30 +01:00
const newCoinDenoms = selectWithdrawalDenominations(
availableAmount,
availableDenoms,
);
if (newCoinDenoms.selectedDenoms.length === 0) {
logger.trace(
`not refreshing, available amount ${amountToPretty(
availableAmount,
)} too small`,
);
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
coins: x.coins,
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
return;
}
2021-08-24 14:25:46 +02:00
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
updateGroupStatus(rg);
2021-06-09 15:14:17 +02:00
await tx.refreshGroups.put(rg);
});
ws.notify({ type: NotificationType.RefreshUnwarranted });
return;
}
2020-12-14 16:44:42 +01:00
const sessionSecretSeed = encodeCrock(getRandomBytes(64));
// Store refresh session for this coin in the database.
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
coins: x.coins,
}))
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
return;
}
if (rg.refreshSessionPerCoin[coinIndex]) {
return;
}
2020-12-14 16:44:42 +01:00
rg.refreshSessionPerCoin[coinIndex] = {
norevealIndex: undefined,
sessionSecretSeed: sessionSecretSeed,
newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
count: x.count,
denomPubHash: x.denomPubHash,
2020-12-14 16:44:42 +01:00
})),
amountRefreshOutput: newCoinDenoms.totalCoinValue,
};
2021-06-09 15:14:17 +02:00
await tx.refreshGroups.put(rg);
});
logger.info(
`created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
);
ws.notify({ type: NotificationType.RefreshStarted });
}
function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
2022-03-18 15:32:41 +01:00
return Duration.fromSpec({
seconds: 5,
});
}
async function refreshMelt(
ws: InternalWalletState,
refreshGroupId: string,
coinIndex: number,
): Promise<void> {
2021-06-09 15:14:17 +02:00
const d = await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
coins: x.coins,
denominations: x.denominations,
}))
.runReadWrite(async (tx) => {
const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
if (!refreshGroup) {
return;
}
const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
if (!refreshSession) {
return;
}
if (refreshSession.norevealIndex !== undefined) {
return;
}
2021-06-09 15:14:17 +02:00
const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
2022-01-13 22:01:14 +01:00
const oldDenom = await ws.getDenomInfo(
ws,
tx,
2021-06-09 15:14:17 +02:00
oldCoin.exchangeBaseUrl,
oldCoin.denomPubHash,
2022-01-13 22:01:14 +01:00
);
2021-06-09 15:14:17 +02:00
checkDbInvariant(
!!oldDenom,
"denomination for melted coin doesn't exist",
);
2020-12-14 16:44:42 +01:00
2021-06-09 15:14:17 +02:00
const newCoinDenoms: RefreshNewDenomInfo[] = [];
2021-06-09 15:14:17 +02:00
for (const dh of refreshSession.newDenoms) {
2022-01-13 22:01:14 +01:00
const newDenom = await ws.getDenomInfo(
ws,
tx,
2021-06-09 15:14:17 +02:00
oldCoin.exchangeBaseUrl,
dh.denomPubHash,
2022-01-13 22:01:14 +01:00
);
2021-06-09 15:14:17 +02:00
checkDbInvariant(
!!newDenom,
"new denomination for refresh not in database",
);
newCoinDenoms.push({
count: dh.count,
denomPub: newDenom.denomPub,
2022-03-15 17:51:05 +01:00
denomPubHash: newDenom.denomPubHash,
2021-06-09 15:14:17 +02:00
feeWithdraw: newDenom.feeWithdraw,
value: newDenom.value,
});
}
return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
2020-12-14 16:44:42 +01:00
});
2021-06-09 15:14:17 +02:00
if (!d) {
return;
}
2021-06-09 15:14:17 +02:00
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");
}
2020-12-14 16:44:42 +01:00
const derived = await ws.cryptoApi.deriveRefreshSession({
exchangeProtocolVersion,
2020-12-14 16:44:42 +01:00
kappa: 3,
meltCoinDenomPubHash: oldCoin.denomPubHash,
meltCoinPriv: oldCoin.coinPriv,
meltCoinPub: oldCoin.coinPub,
feeRefresh: oldDenom.feeRefresh,
newCoinDenoms,
sessionSecretSeed: refreshSession.sessionSecretSeed,
});
2020-03-09 12:07:46 +01:00
const reqUrl = new URL(
2020-12-14 16:44:42 +01:00
`coins/${oldCoin.coinPub}/melt`,
oldCoin.exchangeBaseUrl,
2020-03-09 12:07:46 +01:00
);
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),
};
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
2021-11-27 20:56:58 +01:00
return await ws.http.postJson(reqUrl.href, meltReqBody, {
timeout: getRefreshRequestTimeout(refreshGroup),
});
});
2021-11-03 13:17:57 +01:00
if (resp.status === HttpStatusCode.NotFound) {
2021-08-24 14:25:46 +02:00
const errDetails = await readUnexpectedResponseDetails(resp);
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
return;
}
if (rg.timestampFinished) {
return;
}
if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
return;
}
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Frozen;
rg.lastErrorPerCoin[coinIndex] = errDetails;
updateGroupStatus(rg);
await tx.refreshGroups.put(rg);
});
return;
}
const meltResponse = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeMeltResponse(),
);
const norevealIndex = meltResponse.noreveal_index;
refreshSession.norevealIndex = norevealIndex;
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
return;
}
if (rg.timestampFinished) {
return;
}
const rs = rg.refreshSessionPerCoin[coinIndex];
if (!rs) {
return;
}
if (rs.norevealIndex !== undefined) {
return;
}
rs.norevealIndex = norevealIndex;
await tx.refreshGroups.put(rg);
});
2019-12-05 19:38:19 +01:00
ws.notify({
type: NotificationType.RefreshMelted,
});
}
2022-03-15 17:51:05 +01:00
export async function assembleRefreshRevealRequest(args: {
2022-03-23 21:24:23 +01:00
cryptoApi: TalerCryptoInterface;
2022-03-15 17:51:05 +01:00
derived: DerivedRefreshSession;
norevealIndex: number;
oldCoinPub: CoinPublicKeyString;
oldCoinPriv: string;
newDenoms: {
denomPubHash: string;
count: number;
}[];
}): 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;
2022-03-23 21:24:23 +01:00
const linkSig = await cryptoApi.signCoinLink({
coinEv: planchets[newCoinIndex].coinEv,
newDenomHash: dsel.denomPubHash,
oldCoinPriv: oldCoinPriv,
oldCoinPub: oldCoinPub,
transferPub: derived.transferPubs[norevealIndex],
});
linkSigs.push(linkSig.sig);
2022-03-15 17:51:05 +01:00
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,
};
return req;
}
async function refreshReveal(
ws: InternalWalletState,
refreshGroupId: string,
coinIndex: number,
): Promise<void> {
2022-01-13 22:01:14 +01:00
logger.info("doing refresh reveal");
2021-06-09 15:14:17 +02:00
const d = await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
coins: x.coins,
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
if (!refreshGroup) {
return;
}
const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
if (!refreshSession) {
return;
}
const norevealIndex = refreshSession.norevealIndex;
if (norevealIndex === undefined) {
throw Error("can't reveal without melting first");
}
2020-12-14 16:44:42 +01:00
2021-06-09 15:14:17 +02:00
const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
2022-01-13 22:01:14 +01:00
const oldDenom = await ws.getDenomInfo(
ws,
tx,
2021-06-09 15:14:17 +02:00
oldCoin.exchangeBaseUrl,
oldCoin.denomPubHash,
2022-01-13 22:01:14 +01:00
);
2021-06-09 15:14:17 +02:00
checkDbInvariant(
!!oldDenom,
"denomination for melted coin doesn't exist",
);
2020-12-14 16:44:42 +01:00
2021-06-09 15:14:17 +02:00
const newCoinDenoms: RefreshNewDenomInfo[] = [];
2020-12-14 16:44:42 +01:00
2021-06-09 15:14:17 +02:00
for (const dh of refreshSession.newDenoms) {
2022-01-13 22:01:14 +01:00
const newDenom = await ws.getDenomInfo(
ws,
tx,
2021-06-09 15:14:17 +02:00
oldCoin.exchangeBaseUrl,
dh.denomPubHash,
2022-01-13 22:01:14 +01:00
);
2021-06-09 15:14:17 +02:00
checkDbInvariant(
!!newDenom,
"new denomination for refresh not in database",
);
newCoinDenoms.push({
count: dh.count,
denomPub: newDenom.denomPub,
2022-03-15 17:51:05 +01:00
denomPubHash: newDenom.denomPubHash,
2021-06-09 15:14:17 +02:00
feeWithdraw: newDenom.feeWithdraw,
value: newDenom.value,
});
}
return {
oldCoin,
oldDenom,
newCoinDenoms,
refreshSession,
refreshGroup,
norevealIndex,
};
2020-12-14 16:44:42 +01:00
});
2021-06-09 15:14:17 +02:00
if (!d) {
return;
2020-12-14 16:44:42 +01:00
}
2021-06-09 15:14:17 +02:00
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");
}
2020-12-14 16:44:42 +01:00
const derived = await ws.cryptoApi.deriveRefreshSession({
exchangeProtocolVersion,
2020-12-14 16:44:42 +01:00
kappa: 3,
meltCoinDenomPubHash: oldCoin.denomPubHash,
meltCoinPriv: oldCoin.coinPriv,
meltCoinPub: oldCoin.coinPub,
feeRefresh: oldDenom.feeRefresh,
newCoinDenoms,
sessionSecretSeed: refreshSession.sessionSecretSeed,
});
2020-03-09 12:07:46 +01:00
const reqUrl = new URL(
2020-12-14 16:44:42 +01:00
`refreshes/${derived.hash}/reveal`,
oldCoin.exchangeBaseUrl,
2020-03-09 12:07:46 +01:00
);
2022-03-15 17:51:05 +01:00
const req = await assembleRefreshRevealRequest({
cryptoApi: ws.cryptoApi,
derived,
newDenoms: newCoinDenoms,
norevealIndex: norevealIndex,
oldCoinPriv: oldCoin.coinPriv,
oldCoinPub: oldCoin.coinPub,
});
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[] = [];
2020-12-14 16:44:42 +01:00
for (let i = 0; i < refreshSession.newDenoms.length; i++) {
2022-03-15 17:51:05 +01:00
const ncd = newCoinDenoms[i];
2020-12-14 16:44:42 +01:00
for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
const newCoinIndex = coins.length;
const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
2022-03-15 17:51:05 +01:00
if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
2021-11-17 10:23:22 +01:00
throw Error("cipher unsupported");
}
const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
2022-03-15 17:51:05 +01:00
const denomSig = await ws.cryptoApi.unblindDenominationSignature({
planchet: {
blindingKey: pc.blindingKey,
denomPub: ncd.denomPub,
},
evSig,
});
2020-12-14 16:44:42 +01:00
const coin: CoinRecord = {
blindingKey: pc.blindingKey,
coinPriv: pc.coinPriv,
coinPub: pc.coinPub,
2022-03-15 17:51:05 +01:00
currentAmount: ncd.value,
denomPubHash: ncd.denomPubHash,
denomSig,
2020-12-14 16:44:42 +01:00
exchangeBaseUrl: oldCoin.exchangeBaseUrl,
status: CoinStatus.Fresh,
coinSource: {
type: CoinSourceType.Refresh,
oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
},
suspended: false,
coinEvHash: pc.coinEvHash,
2020-12-14 16:44:42 +01:00
};
coins.push(coin);
}
}
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
coins: x.coins,
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
logger.warn("no refresh session found");
return;
}
const rs = rg.refreshSessionPerCoin[coinIndex];
if (!rs) {
return;
}
2021-08-24 14:25:46 +02:00
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
updateGroupStatus(rg);
2020-04-06 17:45:41 +02:00
for (const coin of coins) {
2021-06-09 15:14:17 +02:00
await tx.coins.put(coin);
}
2021-06-09 15:14:17 +02:00
await tx.refreshGroups.put(rg);
});
logger.trace("refresh finished (end of reveal)");
2019-12-05 19:38:19 +01:00
ws.notify({
type: NotificationType.RefreshRevealed,
});
}
async function setupRefreshRetry(
ws: InternalWalletState,
refreshGroupId: string,
options: {
reset: boolean;
},
): Promise<void> {
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const r = await tx.refreshGroups.get(refreshGroupId);
if (!r) {
return;
}
if (options.reset) {
r.retryInfo = resetRetryInfo();
} else {
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
delete r.lastError;
await tx.refreshGroups.put(r);
});
}
async function reportRefreshError(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
refreshGroupId: string,
err: TalerErrorDetail | undefined,
2019-12-05 19:38:19 +01:00
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const r = await tx.refreshGroups.get(refreshGroupId);
if (!r) {
return;
}
if (!r.retryInfo) {
logger.error(
"reported error for inactive refresh group (no retry info)",
);
2021-06-09 15:14:17 +02:00
}
r.lastError = err;
await tx.refreshGroups.put(r);
});
if (err) {
ws.notify({ type: NotificationType.RefreshOperationError, error: err });
}
2019-12-05 19:38:19 +01:00
}
/**
* Actually process a refresh group that has been created.
*/
export async function processRefreshGroup(
ws: InternalWalletState,
refreshGroupId: string,
options: {
forceNow?: boolean;
} = {},
): Promise<void> {
await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
const onOpErr = (e: TalerErrorDetail): Promise<void> =>
reportRefreshError(ws, refreshGroupId, e);
return await guardOperationException(
async () => await processRefreshGroupImpl(ws, refreshGroupId, options),
2019-12-05 19:38:19 +01:00
onOpErr,
);
});
}
async function processRefreshGroupImpl(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
refreshGroupId: string,
options: {
forceNow?: boolean;
} = {},
2020-04-07 10:07:32 +02:00
): Promise<void> {
const forceNow = options.forceNow ?? false;
2022-01-13 22:01:14 +01:00
logger.info(`processing refresh group ${refreshGroupId}`);
await setupRefreshRetry(ws, refreshGroupId, { reset: forceNow });
2021-06-09 15:14:17 +02:00
const refreshGroup = await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
}))
.runReadOnly(async (tx) => {
return tx.refreshGroups.get(refreshGroupId);
});
if (!refreshGroup) {
return;
}
2019-12-16 16:20:45 +01:00
if (refreshGroup.timestampFinished) {
return;
}
2021-06-09 15:14:17 +02:00
// Process refresh sessions of the group in parallel.
logger.trace("processing refresh sessions for old coins");
const ps = refreshGroup.oldCoinPubs.map((x, i) =>
processRefreshSession(ws, refreshGroupId, i).catch((x) => {
if (x instanceof CryptoApiStoppedError) {
logger.info(
2022-03-30 20:42:07 +02:00
"crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
);
} else {
logger.warn("process refresh session got exception");
logger.warn(`exc ${x}`);
logger.warn(`exc stack ${x.stack}`);
}
}),
);
try {
logger.trace("waiting for refreshes");
await Promise.all(ps);
logger.trace("refresh finished");
} catch (e) {
logger.warn("process refresh sessions got exception");
logger.warn(`exception: ${e}`);
}
}
async function processRefreshSession(
ws: InternalWalletState,
refreshGroupId: string,
coinIndex: number,
2020-04-07 10:07:32 +02:00
): Promise<void> {
2022-01-13 22:01:14 +01:00
logger.info(
2020-03-09 12:07:46 +01:00
`processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
);
2021-06-09 15:14:17 +02:00
let refreshGroup = await ws.db
.mktx((x) => ({ refreshGroups: x.refreshGroups }))
.runReadOnly(async (tx) => {
return tx.refreshGroups.get(refreshGroupId);
});
if (!refreshGroup) {
return;
}
2021-08-24 14:25:46 +02:00
if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
return;
}
if (!refreshGroup.refreshSessionPerCoin[coinIndex]) {
await refreshCreateSession(ws, refreshGroupId, coinIndex);
2021-06-09 15:14:17 +02:00
refreshGroup = await ws.db
.mktx((x) => ({ refreshGroups: x.refreshGroups }))
.runReadOnly(async (tx) => {
return tx.refreshGroups.get(refreshGroupId);
});
if (!refreshGroup) {
return;
}
}
const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
if (!refreshSession) {
2021-08-24 14:25:46 +02:00
if (refreshGroup.statusPerCoin[coinIndex] !== RefreshCoinStatus.Finished) {
throw Error(
"BUG: refresh session was not created and coin not marked as finished",
);
}
return;
}
if (refreshSession.norevealIndex === undefined) {
await refreshMelt(ws, refreshGroupId, coinIndex);
}
await refreshReveal(ws, refreshGroupId, coinIndex);
}
/**
* 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 ensure that
* the remaining amount was updated correctly before the coin was deposited or
* credited.
*
* The caller must also ensure that the coins that should be refreshed exist
* in the current database transaction.
*/
export async function createRefreshGroup(
2020-07-23 14:05:17 +02:00
ws: InternalWalletState,
2021-06-09 15:14:17 +02:00
tx: GetReadWriteAccess<{
denominations: typeof WalletStoresV1.denominations;
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
}>,
oldCoinPubs: CoinPublicKey[],
reason: RefreshReason,
): Promise<RefreshGroupId> {
const refreshGroupId = encodeCrock(getRandomBytes(32));
const inputPerCoin: AmountJson[] = [];
const estimatedOutputPerCoin: AmountJson[] = [];
const denomsPerExchange: Record<string, DenominationRecord[]> = {};
const getDenoms = async (
exchangeBaseUrl: string,
): Promise<DenominationRecord[]> => {
if (denomsPerExchange[exchangeBaseUrl]) {
return denomsPerExchange[exchangeBaseUrl];
}
2021-06-09 15:14:17 +02:00
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(exchangeBaseUrl)
.filter((x) => {
return isWithdrawableDenom(x);
});
denomsPerExchange[exchangeBaseUrl] = allDenoms;
return allDenoms;
};
for (const ocp of oldCoinPubs) {
2021-06-09 15:14:17 +02:00
const coin = await tx.coins.get(ocp.coinPub);
checkDbInvariant(!!coin, "coin must be in database");
2022-01-13 22:01:14 +01:00
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
2020-09-08 17:33:10 +02:00
coin.denomPubHash,
2022-01-13 22:01:14 +01:00
);
checkDbInvariant(
!!denom,
"denomination for existing coin must be in database",
);
const refreshAmount = coin.currentAmount;
inputPerCoin.push(refreshAmount);
coin.currentAmount = Amounts.getZero(refreshAmount.currency);
coin.status = CoinStatus.Dormant;
2021-06-09 15:14:17 +02:00
await tx.coins.put(coin);
const denoms = await getDenoms(coin.exchangeBaseUrl);
const cost = getTotalRefreshCost(denoms, denom, refreshAmount);
const output = Amounts.sub(refreshAmount, cost).amount;
estimatedOutputPerCoin.push(output);
}
const refreshGroup: RefreshGroupRecord = {
2022-01-11 21:00:12 +01:00
operationStatus: OperationStatus.Pending,
2019-12-16 16:20:45 +01:00
timestampFinished: undefined,
2021-08-24 14:25:46 +02:00
statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
lastError: undefined,
2019-12-16 12:53:22 +01:00
lastErrorPerCoin: {},
2020-03-30 12:39:32 +02:00
oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
reason,
refreshGroupId,
2021-08-24 14:25:46 +02:00
refreshSessionPerCoin: oldCoinPubs.map(() => undefined),
retryInfo: resetRetryInfo(),
inputPerCoin,
estimatedOutputPerCoin,
2022-03-18 15:32:41 +01:00
timestampCreated: TalerProtocolTimestamp.now(),
};
if (oldCoinPubs.length == 0) {
logger.warn("created refresh group with zero coins");
2022-03-18 15:32:41 +01:00
refreshGroup.timestampFinished = TalerProtocolTimestamp.now();
2022-01-13 22:01:14 +01:00
refreshGroup.operationStatus = OperationStatus.Finished;
}
2021-06-09 15:14:17 +02:00
await tx.refreshGroups.put(refreshGroup);
2020-07-23 14:05:17 +02:00
2022-01-13 22:01:14 +01:00
logger.info(`created refresh group ${refreshGroupId}`);
2020-07-23 14:05:17 +02:00
processRefreshGroup(ws, refreshGroupId).catch((e) => {
if (e instanceof CryptoApiStoppedError) {
return;
}
2021-11-24 01:57:11 +01:00
logger.warn(`processing refresh group ${refreshGroupId} failed: ${e}`);
});
return {
refreshGroupId,
};
}
2020-09-03 13:59:09 +02:00
2020-09-03 17:08:26 +02:00
/**
* Timestamp after which the wallet would do the next check for an auto-refresh.
*/
2022-03-18 15:32:41 +01:00
function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
const expireWithdraw = AbsoluteTime.fromTimestamp(d.stampExpireWithdraw);
const expireDeposit = AbsoluteTime.fromTimestamp(d.stampExpireDeposit);
const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
2020-09-03 17:08:26 +02:00
const deltaDiv = durationMul(delta, 0.75);
2022-03-18 15:32:41 +01:00
return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
2020-09-03 17:08:26 +02:00
}
/**
* Timestamp after which the wallet would do an auto-refresh.
*/
2022-03-18 15:32:41 +01:00
function getAutoRefreshExecuteThreshold(d: DenominationRecord): AbsoluteTime {
const expireWithdraw = AbsoluteTime.fromTimestamp(d.stampExpireWithdraw);
const expireDeposit = AbsoluteTime.fromTimestamp(d.stampExpireDeposit);
const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
2020-09-03 17:08:26 +02:00
const deltaDiv = durationMul(delta, 0.5);
2022-03-18 15:32:41 +01:00
return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
2020-09-03 17:08:26 +02:00
}
2020-09-03 13:59:09 +02:00
export async function autoRefresh(
ws: InternalWalletState,
exchangeBaseUrl: string,
2020-09-03 17:08:26 +02:00
): Promise<void> {
logger.info(`doing auto-refresh check for '${exchangeBaseUrl}'`);
await updateExchangeFromUrl(ws, exchangeBaseUrl, {
forceNow: true,
});
2022-03-18 15:32:41 +01:00
let minCheckThreshold = AbsoluteTime.addDuration(
AbsoluteTime.now(),
durationFromSpec({ days: 1 }),
);
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
coins: x.coins,
denominations: x.denominations,
refreshGroups: x.refreshGroups,
exchanges: x.exchanges,
}))
.runReadWrite(async (tx) => {
const exchange = await tx.exchanges.get(exchangeBaseUrl);
2020-09-03 17:08:26 +02:00
if (!exchange) {
return;
}
2021-06-09 15:14:17 +02:00
const coins = await tx.coins.indexes.byBaseUrl
.iter(exchangeBaseUrl)
2020-09-03 17:08:26 +02:00
.toArray();
const refreshCoins: CoinPublicKey[] = [];
for (const coin of coins) {
if (coin.status !== CoinStatus.Fresh) {
continue;
}
if (coin.suspended) {
continue;
}
2021-06-09 15:14:17 +02:00
const denom = await tx.denominations.get([
2020-09-03 17:08:26 +02:00
exchangeBaseUrl,
2020-09-08 17:33:10 +02:00
coin.denomPubHash,
2020-09-03 17:08:26 +02:00
]);
if (!denom) {
logger.warn("denomination not in database");
continue;
}
const executeThreshold = getAutoRefreshExecuteThreshold(denom);
2022-03-18 15:32:41 +01:00
if (AbsoluteTime.isExpired(executeThreshold)) {
2020-09-03 17:08:26 +02:00
refreshCoins.push(coin);
} else {
const checkThreshold = getAutoRefreshCheckThreshold(denom);
2022-03-18 15:32:41 +01:00
minCheckThreshold = AbsoluteTime.min(
minCheckThreshold,
checkThreshold,
);
2020-09-03 17:08:26 +02:00
}
}
if (refreshCoins.length > 0) {
const res = await createRefreshGroup(
ws,
tx,
refreshCoins,
RefreshReason.Scheduled,
);
logger.info(
`created refresh group for auto-refresh (${res.refreshGroupId})`,
);
2020-09-03 17:08:26 +02:00
}
logger.info(
2022-03-18 15:32:41 +01:00
`current wallet time: ${AbsoluteTime.toIsoString(AbsoluteTime.now())}`,
);
logger.info(
2022-03-18 15:32:41 +01:00
`next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`,
2020-09-03 17:08:26 +02:00
);
2022-03-18 15:32:41 +01:00
exchange.nextRefreshCheck = AbsoluteTime.toTimestamp(minCheckThreshold);
2021-06-09 15:14:17 +02:00
await tx.exchanges.put(exchange);
});
2020-09-03 17:08:26 +02:00
}