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

437 lines
12 KiB
TypeScript

/*
This file is part of GNU Taler
(C) 2022 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/>
*/
/**
* Imports.
*/
import {
AgeRestriction,
AmountJson,
Amounts,
CoinRefreshRequest,
CoinStatus,
ExchangeEntryStatus,
ExchangeListItem,
ExchangeTosStatus,
j2s,
Logger,
OperationErrorInfo,
RefreshReason,
TalerErrorCode,
TalerErrorDetail,
TombstoneIdStr,
TransactionIdStr,
TransactionType,
} from "@gnu-taler/taler-util";
import {
WalletStoresV1,
CoinRecord,
ExchangeDetailsRecord,
ExchangeRecord,
} from "../db.js";
import { makeErrorDetail, TalerError } from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
import {
OperationAttemptResult,
OperationAttemptResultType,
RetryInfo,
} from "../util/retries.js";
import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js";
const logger = new Logger("operations/common.ts");
export interface CoinsSpendInfo {
coinPubs: string[];
contributions: AmountJson[];
refreshReason: RefreshReason;
/**
* Identifier for what the coin has been spent for.
*/
allocationId: TransactionIdStr;
}
export async function makeCoinAvailable(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
denominations: typeof WalletStoresV1.denominations;
}>,
coinRecord: CoinRecord,
): Promise<void> {
checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
const existingCoin = await tx.coins.get(coinRecord.coinPub);
if (existingCoin) {
return;
}
const denom = await tx.denominations.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
]);
checkDbInvariant(!!denom);
const ageRestriction = coinRecord.maxAge;
let car = await tx.coinAvailability.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
ageRestriction,
]);
if (!car) {
car = {
maxAge: ageRestriction,
amountFrac: denom.amountFrac,
amountVal: denom.amountVal,
currency: denom.currency,
denomPubHash: denom.denomPubHash,
exchangeBaseUrl: denom.exchangeBaseUrl,
freshCoinCount: 0,
};
}
car.freshCoinCount++;
await tx.coins.put(coinRecord);
await tx.coinAvailability.put(car);
}
export async function spendCoins(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
refreshGroups: typeof WalletStoresV1.refreshGroups;
denominations: typeof WalletStoresV1.denominations;
}>,
csi: CoinsSpendInfo,
): Promise<void> {
if (csi.coinPubs.length != csi.contributions.length) {
throw Error("assertion failed");
}
if (csi.coinPubs.length === 0) {
return;
}
let refreshCoinPubs: CoinRefreshRequest[] = [];
for (let i = 0; i < csi.coinPubs.length; i++) {
const coin = await tx.coins.get(csi.coinPubs[i]);
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
checkDbInvariant(!!denom);
const coinAvailability = await tx.coinAvailability.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
coin.maxAge,
]);
checkDbInvariant(!!coinAvailability);
const contrib = csi.contributions[i];
if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.spendAllocation;
if (!alloc) {
continue;
}
if (alloc.id !== csi.allocationId) {
// FIXME: assign error code
logger.info("conflicting coin allocation ID");
logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`);
throw Error("conflicting coin allocation (id)");
}
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
// FIXME: assign error code
throw Error("conflicting coin allocation (contrib)");
}
continue;
}
coin.status = CoinStatus.Dormant;
coin.spendAllocation = {
id: csi.allocationId,
amount: Amounts.stringify(contrib),
};
const remaining = Amounts.sub(denom.value, contrib);
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
}
refreshCoinPubs.push({
amount: Amounts.stringify(remaining.amount),
coinPub: coin.coinPub,
});
checkDbInvariant(!!coinAvailability);
if (coinAvailability.freshCoinCount === 0) {
throw Error(
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
);
}
coinAvailability.freshCoinCount--;
await tx.coins.put(coin);
await tx.coinAvailability.put(coinAvailability);
}
await ws.refreshOps.createRefreshGroup(
ws,
tx,
Amounts.currencyOf(csi.contributions[0]),
refreshCoinPubs,
RefreshReason.PayMerchant,
{
originatingTransactionId: csi.allocationId,
},
);
}
export async function storeOperationError(
ws: InternalWalletState,
pendingTaskId: string,
e: TalerErrorDetail,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
let retryRecord = await tx.operationRetries.get(pendingTaskId);
if (!retryRecord) {
retryRecord = {
id: pendingTaskId,
lastError: e,
retryInfo: RetryInfo.reset(),
};
} else {
retryRecord.lastError = e;
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
}
await tx.operationRetries.put(retryRecord);
});
}
export async function storeOperationPending(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
let retryRecord = await tx.operationRetries.get(pendingTaskId);
if (!retryRecord) {
retryRecord = {
id: pendingTaskId,
retryInfo: RetryInfo.reset(),
};
} else {
delete retryRecord.lastError;
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
}
await tx.operationRetries.put(retryRecord);
});
}
export async function runOperationWithErrorReporting<T1, T2>(
ws: InternalWalletState,
opId: string,
f: () => Promise<OperationAttemptResult<T1, T2>>,
): Promise<OperationAttemptResult<T1, T2>> {
let maybeError: TalerErrorDetail | undefined;
try {
const resp = await f();
switch (resp.type) {
case OperationAttemptResultType.Error:
await storeOperationError(ws, opId, resp.errorDetail);
return resp;
case OperationAttemptResultType.Finished:
await storeOperationFinished(ws, opId);
return resp;
case OperationAttemptResultType.Pending:
await storeOperationPending(ws, opId);
return resp;
case OperationAttemptResultType.Longpoll:
return resp;
}
} catch (e) {
if (e instanceof CryptoApiStoppedError) {
if (ws.stopped) {
logger.warn("crypto API stopped during shutdown, ignoring error");
return {
type: OperationAttemptResultType.Error,
errorDetail: makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
{},
"Crypto API stopped during shutdown",
),
};
}
}
if (e instanceof TalerError) {
logger.warn("operation processed resulted in error");
logger.warn(`error was: ${j2s(e.errorDetail)}`);
maybeError = e.errorDetail;
await storeOperationError(ws, opId, maybeError!);
return {
type: OperationAttemptResultType.Error,
errorDetail: e.errorDetail,
};
} else if (e instanceof Error) {
// This is a bug, as we expect pending operations to always
// do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
// or return something.
logger.error(`Uncaught exception: ${e.message}`);
logger.error(`Stack: ${e.stack}`);
maybeError = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
{
stack: e.stack,
},
`unexpected exception (message: ${e.message})`,
);
await storeOperationError(ws, opId, maybeError);
return {
type: OperationAttemptResultType.Error,
errorDetail: maybeError,
};
} else {
logger.error("Uncaught exception, value is not even an error.");
maybeError = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
{},
`unexpected exception (not even an error)`,
);
await storeOperationError(ws, opId, maybeError);
return {
type: OperationAttemptResultType.Error,
errorDetail: maybeError,
};
}
}
}
export async function storeOperationFinished(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
await tx.operationRetries.delete(pendingTaskId);
});
}
export enum TombstoneTag {
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
DeletePayment = "delete-payment",
DeleteTip = "delete-tip",
DeleteRefreshGroup = "delete-refresh-group",
DeleteDepositGroup = "delete-deposit-group",
DeleteRefund = "delete-refund",
DeletePeerPullDebit = "delete-peer-pull-debit",
DeletePeerPushDebit = "delete-peer-push-debit",
}
/**
* Create an event ID from the type and the primary key for the event.
*/
export function makeTransactionId(
type: TransactionType,
...args: string[]
): TransactionIdStr {
return `txn:${type}:${args.map((x) => encodeURIComponent(x)).join(":")}`;
}
export function parseId(
idType: "txn" | "tmb" | "any",
txId: string,
): {
type: TransactionType;
args: string[];
} {
const txnParts = txId.split(":");
if (txnParts.length < 3) {
throw Error("id should have al least 3 parts separated by ':'");
}
const [prefix, typeStr, ...args] = txnParts;
const type = typeStr as TransactionType;
if (idType != "any" && prefix !== idType) {
throw Error(`id should start with ${idType}`);
}
if (args.length === 0) {
throw Error("id should have one or more arguments");
}
return { type, args };
}
/**
* Create an event ID from the type and the primary key for the event.
*/
export function makeTombstoneId(
type: TombstoneTag,
...args: string[]
): TombstoneIdStr {
return `tmb:${type}:${args.map((x) => encodeURIComponent(x)).join(":")}`;
}
export function getExchangeTosStatus(
exchangeDetails: ExchangeDetailsRecord,
): ExchangeTosStatus {
if (!exchangeDetails.tosAccepted) {
return ExchangeTosStatus.New;
}
if (exchangeDetails.tosAccepted?.etag == exchangeDetails.tosCurrentEtag) {
return ExchangeTosStatus.Accepted;
}
return ExchangeTosStatus.Changed;
}
export function makeExchangeListItem(
r: ExchangeRecord,
exchangeDetails: ExchangeDetailsRecord | undefined,
lastError: TalerErrorDetail | undefined,
): ExchangeListItem {
const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
? {
error: lastError,
}
: undefined;
if (!exchangeDetails) {
return {
exchangeBaseUrl: r.baseUrl,
currency: undefined,
tosStatus: ExchangeTosStatus.Unknown,
paytoUris: [],
exchangeStatus: ExchangeEntryStatus.Unknown,
permanent: r.permanent,
ageRestrictionOptions: [],
lastUpdateErrorInfo,
};
}
let exchangeStatus;
exchangeStatus = ExchangeEntryStatus.Ok;
return {
exchangeBaseUrl: r.baseUrl,
currency: exchangeDetails.currency,
tosStatus: getExchangeTosStatus(exchangeDetails),
paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
exchangeStatus,
permanent: r.permanent,
ageRestrictionOptions: exchangeDetails.ageMask
? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
: [],
lastUpdateErrorInfo,
};
}